From 68744838b33c7315c8ebdb0f0d4703d910952923 Mon Sep 17 00:00:00 2001 From: j Date: Sun, 28 Jun 2026 08:22:29 +0700 Subject: [PATCH 1/2] =?UTF-8?q?test:=20unit=20tests=20for=20safety=5Fscore?= =?UTF-8?q?=20and=20activity=5Flikeness=5Fscore=20=E2=80=94=20all=20risk/c?= =?UTF-8?q?omponent=20boundaries?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - tests/test_scoring_safety.py (32 tests): every risk condition boundary-tested with explicit arithmetic: μH threshold (0.55), hydrophobic fraction (0.65), charge density (0.55, uses abs), length (>35 flat +0.25), cysteine (>0.25 flat +0.20), repeat run (>=6 flat +0.15); cumulative clamping, missing μH key - tests/test_scoring_activity.py (29 tests): clamp01 + every scoring component of activity_likeness_score: length (peak at 18, zero at ±43), charge (clamped at cd>=0.50), hydrophobic (peak at 0.45), aromatic bonus (cap at 0.20→0.10 max), amphipathicity bonus (cap at μH=0.8→0.15 max); monotonicity sweep; AMP positive/negative known-sequence spot checks 769 tests total (was 708). --- tests/test_scoring_activity.py | 217 +++++++++++++++++++++++++++++++++ tests/test_scoring_safety.py | 201 ++++++++++++++++++++++++++++++ 2 files changed, 418 insertions(+) create mode 100644 tests/test_scoring_activity.py create mode 100644 tests/test_scoring_safety.py diff --git a/tests/test_scoring_activity.py b/tests/test_scoring_activity.py new file mode 100644 index 00000000..83e428d2 --- /dev/null +++ b/tests/test_scoring_activity.py @@ -0,0 +1,217 @@ +"""Tests for scoring/activity.py — activity_likeness_score and clamp01. + +activity_likeness_score combines: + - length_score (weight 0.24): 1 - min(|length - 18| / 25, 1.0) + - charge_score (weight 0.27): clamp01((charge_density + 0.05) / 0.55) + - 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.15): clamp01(mu_h / 0.8) * 0.15 + +Total max ~0.93. Result is clamped to [0, 1] and rounded to 4 dp. +""" +from __future__ import annotations + +from openamp_foundry.scoring.activity import activity_likeness_score, clamp01 + + +def _feat( + length: int = 18, + charge_density: float = 0.30, + hydrophobic: float = 0.45, + aromatic: float = 0.0, + mu_h: float = 0.0, +) -> dict: + return { + "length": length, + "charge_density": charge_density, + "hydrophobic_fraction": hydrophobic, + "aromatic_fraction": aromatic, + "hydrophobic_moment": mu_h, + } + + +class TestClamp01: + def test_clamps_below_zero(self): + assert clamp01(-5.0) == 0.0 + + def test_clamps_above_one(self): + assert clamp01(3.0) == 1.0 + + def test_passes_through_mid_range(self): + assert abs(clamp01(0.5) - 0.5) < 1e-9 + + def test_at_zero(self): + assert clamp01(0.0) == 0.0 + + def test_at_one(self): + assert clamp01(1.0) == 1.0 + + +class TestActivityLikenessScore: + def test_returns_float(self): + assert isinstance(activity_likeness_score(_feat()), float) + + def test_returns_value_in_unit_interval(self): + for length in [5, 8, 18, 35, 50]: + s = activity_likeness_score(_feat(length=length)) + assert 0.0 <= s <= 1.0, f"score={s} out of range for length={length}" + + def test_result_rounded_to_4dp(self): + s = activity_likeness_score(_feat(length=20, charge_density=0.33)) + assert s == round(s, 4) + + def test_no_hydrophobic_moment_key_defaults_gracefully(self): + feat = { + "length": 18, "charge_density": 0.30, + "hydrophobic_fraction": 0.45, "aromatic_fraction": 0.0, + # hydrophobic_moment deliberately omitted — should default to 0.0 + } + score = activity_likeness_score(feat) + expected = activity_likeness_score(_feat(mu_h=0.0)) + assert score == expected + + +class TestLengthComponent: + def test_optimal_length_18_highest(self): + # length_score peaks at length=18 + s_18 = activity_likeness_score(_feat(length=18)) + s_10 = activity_likeness_score(_feat(length=10)) + s_30 = activity_likeness_score(_feat(length=30)) + assert s_18 > s_10 and s_18 > s_30 + + def test_length_43_scores_zero_length_component(self): + # |43 - 18| / 25 = 1.0 → length_score = 0.0 + # contribution = 0.24 * 0.0 = 0 from length + score_43 = activity_likeness_score(_feat(length=43)) + score_44 = activity_likeness_score(_feat(length=44)) + # Both have zero length_score → same contribution + assert score_43 == score_44 + + def test_shorter_sequence_penalised_vs_optimal(self): + assert activity_likeness_score(_feat(length=5)) < activity_likeness_score(_feat(length=18)) + + def test_longer_sequence_penalised_vs_optimal(self): + assert activity_likeness_score(_feat(length=40)) < activity_likeness_score(_feat(length=18)) + + +class TestChargeComponent: + def test_negative_charge_penalised(self): + neg = activity_likeness_score(_feat(charge_density=-0.5)) + pos = activity_likeness_score(_feat(charge_density=0.3)) + assert pos > neg + + def test_charge_score_formula(self): + # charge_score = clamp01((0.30 + 0.05) / 0.55) = clamp01(0.6364) = 0.6364 + # contribution = 0.27 * 0.6364 ≈ 0.1718 + # At length=18, hydrophobic=0.45, aromatic=0, mu_h=0: + # length_score = 1.0, contribution = 0.24 + # hydro_score = 1.0 - 0 = 1.0, contribution = 0.17 + # total ≈ 0.24 + 0.1718 + 0.17 = 0.5818 + score = activity_likeness_score(_feat(charge_density=0.30)) + assert abs(score - 0.5818) < 0.001 + + def test_increasing_charge_improves_score_up_to_ceiling(self): + low = activity_likeness_score(_feat(charge_density=0.10)) + mid = activity_likeness_score(_feat(charge_density=0.30)) + high = activity_likeness_score(_feat(charge_density=0.50)) + assert low < mid < high + + def test_very_high_charge_clamped(self): + # charge_score saturates at 1.0 when (cd + 0.05) / 0.55 >= 1.0 (cd >= 0.50) + score_0_50 = activity_likeness_score(_feat(charge_density=0.50)) + score_0_90 = activity_likeness_score(_feat(charge_density=0.90)) + assert score_0_50 == score_0_90 + + +class TestHydrophobicComponent: + def test_optimal_hydrophobic_fraction_0_45(self): + # hydro_score = 1 - |0.45-0.45|/0.45 = 1.0 + s_opt = activity_likeness_score(_feat(hydrophobic=0.45)) + s_low = activity_likeness_score(_feat(hydrophobic=0.10)) + s_high = activity_likeness_score(_feat(hydrophobic=0.80)) + assert s_opt > s_low and s_opt > s_high + + def test_hydrophobic_0_0_zero_score_component(self): + # hydro_score = 1 - min(0.45/0.45, 1.0) = 0.0 + score_0 = activity_likeness_score(_feat(hydrophobic=0.00)) + score_0_01 = activity_likeness_score(_feat(hydrophobic=0.01)) + assert score_0 < score_0_01 + + def test_hydrophobic_0_90_penalised(self): + # hydro_score = 1 - min(0.45/0.45, 1.0) = 0.0 (clamped) + s_90 = activity_likeness_score(_feat(hydrophobic=0.90)) + s_45 = activity_likeness_score(_feat(hydrophobic=0.45)) + assert s_90 < s_45 + + +class TestAromaticBonus: + def test_no_aromatic_no_bonus(self): + score_0 = activity_likeness_score(_feat(aromatic=0.0)) + # At aromatic=0, aromatic_bonus = 0; verify by checking aromatic != 0 gives more + score_01 = activity_likeness_score(_feat(aromatic=0.10)) + assert score_01 > score_0 + + def test_aromatic_0_20_full_bonus(self): + # aromatic_bonus = min(0.20/0.20, 1.0) * 0.10 = 0.10 + s_20 = activity_likeness_score(_feat(aromatic=0.20)) + s_25 = activity_likeness_score(_feat(aromatic=0.25)) + # Both give full bonus (capped at 1.0 * 0.10 = 0.10) + assert s_20 == s_25 + + def test_aromatic_half_gives_half_bonus(self): + # aromatic = 0.10 → aromatic_bonus = 0.5 * 0.10 = 0.05 + # aromatic = 0.20 → aromatic_bonus = 1.0 * 0.10 = 0.10 (+0.05) + diff = ( + activity_likeness_score(_feat(aromatic=0.20)) - + activity_likeness_score(_feat(aromatic=0.10)) + ) + assert abs(diff - 0.05) < 0.001 + + +class TestAmphipathicityBonus: + def test_no_amphipathicity_no_bonus(self): + s_0 = activity_likeness_score(_feat(mu_h=0.0)) + s_4 = activity_likeness_score(_feat(mu_h=0.4)) + assert s_4 > s_0 + + def test_mu_h_0_8_full_bonus(self): + # amphipathicity = clamp01(0.8/0.8) * 0.15 = 0.15 + s_08 = activity_likeness_score(_feat(mu_h=0.8)) + s_10 = activity_likeness_score(_feat(mu_h=1.0)) + # Both give full bonus (capped) + assert s_08 == s_10 + + def test_mu_h_0_4_half_bonus(self): + # amphipathicity = clamp01(0.4/0.8) * 0.15 = 0.5 * 0.15 = 0.075 + # diff between mu_h=0.8 and mu_h=0.4 should be 0.075 + diff = ( + activity_likeness_score(_feat(mu_h=0.8)) - + activity_likeness_score(_feat(mu_h=0.4)) + ) + assert abs(diff - 0.075) < 0.001 + + def test_mu_h_improves_score_monotonically_up_to_ceiling(self): + scores = [activity_likeness_score(_feat(mu_h=v)) for v in [0.0, 0.2, 0.4, 0.6, 0.8]] + for a, b in zip(scores, scores[1:]): + assert b > a, f"score did not increase: {a} → {b}" + + +class TestKnownAMPs: + def test_magainin_like_scores_well(self): + # KWKLFKKIGAVLKVL — canonical AMP-like sequence + # length=15, moderate charge, 40% hydrophobic → should score > 0.5 + feat = { + "length": 15, "charge_density": 0.40, + "hydrophobic_fraction": 0.47, "aromatic_fraction": 0.07, + "hydrophobic_moment": 0.55, + } + assert activity_likeness_score(feat) > 0.5 + + def test_polyglutamate_scores_poorly(self): + # EEEEEEEEEEEEEEEE — highly negative, no hydrophobic, not AMP-like + feat = { + "length": 16, "charge_density": -1.0, + "hydrophobic_fraction": 0.0, "aromatic_fraction": 0.0, + "hydrophobic_moment": 0.0, + } + assert activity_likeness_score(feat) < 0.3 diff --git a/tests/test_scoring_safety.py b/tests/test_scoring_safety.py new file mode 100644 index 00000000..088f8af3 --- /dev/null +++ b/tests/test_scoring_safety.py @@ -0,0 +1,201 @@ +"""Tests for scoring/safety.py — safety_score risk conditions. + +safety_score = 1.0 - clamp01(risk), where risk accumulates from: + - μH > 0.55: (μH - 0.55) * 1.5 + - hydrophobic_fraction > 0.65: (hf - 0.65) * 1.8 + - |charge_density| > 0.55: (cd - 0.55) * 1.2 + - length > 35: +0.25 flat + - cysteine_fraction > 0.25: +0.20 flat + - longest_repeat_run >= 6: +0.15 flat + +All boundary conditions are tested with explicit arithmetic. +""" +from __future__ import annotations + +from openamp_foundry.scoring.safety import safety_score + + +def _feat( + length: int = 15, + charge_density: float = 0.30, + hydrophobic: float = 0.40, + cys: float = 0.0, + repeat_run: int = 1, + mu_h: float = 0.0, +) -> dict: + return { + "length": length, + "charge_density": charge_density, + "hydrophobic_fraction": hydrophobic, + "cysteine_fraction": cys, + "longest_repeat_run": repeat_run, + "hydrophobic_moment": mu_h, + } + + +class TestSafetyScoreBaseline: + def test_ideal_amp_scores_one(self): + # No risk factor triggered: no penalties → score = 1.0 + assert safety_score(_feat()) == 1.0 + + def test_returns_float(self): + assert isinstance(safety_score(_feat()), float) + + def test_output_in_unit_interval(self): + # Sweep several parameter combinations; score must stay in [0, 1] + for mu_h in [0.0, 0.4, 0.6, 0.9]: + for hf in [0.0, 0.4, 0.7, 1.0]: + score = safety_score(_feat(mu_h=mu_h, hydrophobic=hf, length=40)) + assert 0.0 <= score <= 1.0, f"score={score} out of range" + + def test_result_rounded_to_4dp(self): + s = safety_score(_feat(mu_h=0.6)) + assert s == round(s, 4) + + +class TestHydrophobicMomentPenalty: + def test_mu_h_at_threshold_no_penalty(self): + # μH exactly 0.55 → no penalty (condition is > 0.55) + assert safety_score(_feat(mu_h=0.55)) == 1.0 + + def test_mu_h_just_above_threshold_penalised(self): + # μH = 0.56 → risk = (0.56 - 0.55) * 1.5 = 0.015 → score = 0.985 + score = safety_score(_feat(mu_h=0.56)) + assert abs(score - 0.985) < 0.001 + + def test_mu_h_0_7_penalty_correct(self): + # μH = 0.70 → risk = (0.70 - 0.55) * 1.5 = 0.225 → score = 0.775 + score = safety_score(_feat(mu_h=0.70)) + assert abs(score - 0.775) < 0.001 + + def test_mu_h_0_9_penalty_correct(self): + # μH = 0.90 → risk = (0.90 - 0.55) * 1.5 = 0.525 → score = 0.475 + score = safety_score(_feat(mu_h=0.90)) + assert abs(score - 0.475) < 0.001 + + def test_high_mu_h_below_low_mu_h(self): + assert safety_score(_feat(mu_h=0.80)) < safety_score(_feat(mu_h=0.40)) + + def test_mu_h_penalty_capped_at_zero_score(self): + # μH = 1.22 → risk ≥ 1.0 → clamped to 1.0 → score = 0.0 + score = safety_score(_feat(mu_h=1.22)) + assert score == 0.0 + + +class TestHydrophobicFractionPenalty: + def test_hydrophobic_at_threshold_no_penalty(self): + # hf exactly 0.65 → no penalty (condition is > 0.65) + assert safety_score(_feat(hydrophobic=0.65)) == 1.0 + + def test_hydrophobic_0_70_penalty_correct(self): + # hf = 0.70 → risk = (0.70 - 0.65) * 1.8 = 0.09 → score = 0.91 + score = safety_score(_feat(hydrophobic=0.70)) + assert abs(score - 0.91) < 0.001 + + def test_hydrophobic_0_80_penalty_correct(self): + # hf = 0.80 → risk = (0.80 - 0.65) * 1.8 = 0.27 → score = 0.73 + score = safety_score(_feat(hydrophobic=0.80)) + assert abs(score - 0.73) < 0.001 + + def test_very_high_hydrophobic_penalised_more(self): + assert safety_score(_feat(hydrophobic=0.90)) < safety_score(_feat(hydrophobic=0.70)) + + +class TestChargeDensityPenalty: + def test_charge_density_at_threshold_no_penalty(self): + # |cd| exactly 0.55 → no penalty (condition is > 0.55) + assert safety_score(_feat(charge_density=0.55)) == 1.0 + + def test_negative_charge_density_uses_abs(self): + # charge_density = -0.60 → |cd| = 0.60 > 0.55 → penalty + score_neg = safety_score(_feat(charge_density=-0.60)) + score_pos = safety_score(_feat(charge_density=0.60)) + assert score_neg == score_pos + + def test_charge_density_0_70_penalty_correct(self): + # cd = 0.70 → risk = (0.70 - 0.55) * 1.2 = 0.18 → score = 0.82 + score = safety_score(_feat(charge_density=0.70)) + assert abs(score - 0.82) < 0.001 + + def test_charge_density_below_threshold_no_penalty(self): + assert safety_score(_feat(charge_density=0.30)) == 1.0 + + +class TestLengthPenalty: + def test_length_35_no_penalty(self): + # exactly 35 → no penalty (condition is > 35) + assert safety_score(_feat(length=35)) == 1.0 + + def test_length_36_flat_penalty(self): + # length > 35 → risk += 0.25 → score = 0.75 + score = safety_score(_feat(length=36)) + assert abs(score - 0.75) < 0.001 + + def test_length_100_same_flat_penalty(self): + # flat penalty regardless of how long (not graduated) + assert abs(safety_score(_feat(length=100)) - safety_score(_feat(length=36))) < 0.001 + + def test_short_sequence_no_length_penalty(self): + # length=8 is short but below the length > 35 threshold + assert safety_score(_feat(length=8)) == 1.0 + + +class TestCysteinePenalty: + def test_cysteine_at_threshold_no_penalty(self): + # cys exactly 0.25 → no penalty (condition is > 0.25) + assert safety_score(_feat(cys=0.25)) == 1.0 + + def test_cysteine_0_26_penalty_applied(self): + # cys = 0.26 → risk += 0.20 → score = 0.80 + score = safety_score(_feat(cys=0.26)) + assert abs(score - 0.80) < 0.001 + + def test_cysteine_1_0_same_flat_penalty(self): + # flat penalty regardless of fraction amount + assert abs(safety_score(_feat(cys=1.0)) - safety_score(_feat(cys=0.26))) < 0.001 + + +class TestRepeatRunPenalty: + def test_repeat_run_5_no_penalty(self): + # run = 5 → no penalty (condition is >= 6) + assert safety_score(_feat(repeat_run=5)) == 1.0 + + def test_repeat_run_6_penalty_applied(self): + # run >= 6 → risk += 0.15 → score = 0.85 + score = safety_score(_feat(repeat_run=6)) + assert abs(score - 0.85) < 0.001 + + def test_repeat_run_20_same_flat_penalty(self): + # flat penalty regardless of run length + assert abs(safety_score(_feat(repeat_run=20)) - safety_score(_feat(repeat_run=6))) < 0.001 + + +class TestCumulativePenalties: + def test_multiple_penalties_accumulate(self): + # μH=0.70 (+0.225) + hf=0.80 (+0.27) = 0.495 → score = 0.505 + score = safety_score(_feat(mu_h=0.70, hydrophobic=0.80)) + assert abs(score - 0.505) < 0.001 + + def test_all_penalties_clamp_to_zero(self): + # All risk factors active at extremes → risk ≥ 1.0 → score = 0.0 + score = safety_score(_feat( + mu_h=0.90, hydrophobic=0.90, charge_density=0.80, + length=50, cys=0.50, repeat_run=8 + )) + assert score == 0.0 + + def test_two_penalties_add_correctly(self): + # length > 35 (+0.25) + cysteine > 0.25 (+0.20) = 0.45 → score = 0.55 + score = safety_score(_feat(length=40, cys=0.30)) + assert abs(score - 0.55) < 0.001 + + def test_no_hydrophobic_moment_feature_defaults_to_zero(self): + feat = { + "length": 15, "charge_density": 0.30, + "hydrophobic_fraction": 0.40, "cysteine_fraction": 0.0, + "longest_repeat_run": 1, + # Note: hydrophobic_moment deliberately omitted + } + # Should default to mu_h=0.0, no crash + score = safety_score(feat) + assert score == 1.0 From 20d4c0f05b9435ffb8513995534abd02f7d83afe Mon Sep 17 00:00:00 2001 From: j Date: Sun, 28 Jun 2026 08:32:40 +0700 Subject: [PATCH 2/2] fix: add midrange normalization-constant tests; move misplaced safety test - test_scoring_activity.py: add test_length_30_midrange_verifies_normalization_constant (catches silent change to the /25 denominator) and test_hydrophobic_225_midrange_verifies_normalization_constant (catches silent change to the /0.45 denominator); these were flagged by code reviewer as the only tests that would fail if constants were changed - test_scoring_safety.py: move test_no_hydrophobic_moment_feature_defaults_to_zero from TestCumulativePenalties to TestSafetyScoreBaseline (it tests a default parameter, not penalty accumulation) --- tests/test_scoring_activity.py | 14 ++++++++++++++ tests/test_scoring_safety.py | 22 +++++++++++----------- 2 files changed, 25 insertions(+), 11 deletions(-) diff --git a/tests/test_scoring_activity.py b/tests/test_scoring_activity.py index 83e428d2..bc764f53 100644 --- a/tests/test_scoring_activity.py +++ b/tests/test_scoring_activity.py @@ -93,6 +93,13 @@ def test_shorter_sequence_penalised_vs_optimal(self): def test_longer_sequence_penalised_vs_optimal(self): assert activity_likeness_score(_feat(length=40)) < activity_likeness_score(_feat(length=18)) + def test_length_30_midrange_verifies_normalization_constant(self): + # |30-18|/25 = 0.48 → length_score = 0.52 → 0.24*0.52 = 0.1248 + # + 0.27*clamp01(0.35/0.55) = 0.1718 + 0.17*1.0 = 0.4666 + # Changing the constant 25 to 20 would give |30-18|/20=0.60 → score≠0.4666 + score = activity_likeness_score(_feat(length=30)) + assert abs(score - 0.4666) < 0.001 + class TestChargeComponent: def test_negative_charge_penalised(self): @@ -143,6 +150,13 @@ def test_hydrophobic_0_90_penalised(self): s_45 = activity_likeness_score(_feat(hydrophobic=0.45)) assert s_90 < s_45 + def test_hydrophobic_225_midrange_verifies_normalization_constant(self): + # |0.225-0.45|/0.45 = 0.5 → hydro_score = 0.5 → 0.17*0.5 = 0.085 + # + 0.24*1.0 (length=18) + 0.27*clamp01(0.35/0.55) = 0.4968 + # Changing the denominator 0.45 to 0.40 would give |0.225-0.45|/0.40=0.5625 → score≠0.4968 + score = activity_likeness_score(_feat(hydrophobic=0.225)) + assert abs(score - 0.4968) < 0.001 + class TestAromaticBonus: def test_no_aromatic_no_bonus(self): diff --git a/tests/test_scoring_safety.py b/tests/test_scoring_safety.py index 088f8af3..e891288a 100644 --- a/tests/test_scoring_safety.py +++ b/tests/test_scoring_safety.py @@ -52,6 +52,17 @@ def test_result_rounded_to_4dp(self): s = safety_score(_feat(mu_h=0.6)) assert s == round(s, 4) + def test_no_hydrophobic_moment_feature_defaults_to_zero(self): + feat = { + "length": 15, "charge_density": 0.30, + "hydrophobic_fraction": 0.40, "cysteine_fraction": 0.0, + "longest_repeat_run": 1, + # Note: hydrophobic_moment deliberately omitted + } + # Should default to mu_h=0.0, no crash + score = safety_score(feat) + assert score == 1.0 + class TestHydrophobicMomentPenalty: def test_mu_h_at_threshold_no_penalty(self): @@ -188,14 +199,3 @@ def test_two_penalties_add_correctly(self): # length > 35 (+0.25) + cysteine > 0.25 (+0.20) = 0.45 → score = 0.55 score = safety_score(_feat(length=40, cys=0.30)) assert abs(score - 0.55) < 0.001 - - def test_no_hydrophobic_moment_feature_defaults_to_zero(self): - feat = { - "length": 15, "charge_density": 0.30, - "hydrophobic_fraction": 0.40, "cysteine_fraction": 0.0, - "longest_repeat_run": 1, - # Note: hydrophobic_moment deliberately omitted - } - # Should default to mu_h=0.0, no crash - score = safety_score(feat) - assert score == 1.0