From 9fa736255e5ce371f9f7d482ab34ebc3b985bb30 Mon Sep 17 00:00:00 2001 From: j Date: Sun, 28 Jun 2026 14:44:29 +0700 Subject: [PATCH 1/2] feat: aggregation-safe mutation generation + balanced K/R charge variants + SynthQC continuous agg score Generator, scorer, and QC now use the same aggregation threshold (VILMFW run >= 4) consistently: - generate_aggregation_safe_double_variants(): filters double substitutions that would create hydrophobic runs >= 4 before they enter the candidate pool, preventing the generator from producing sequences immediately penalised by synthesis_feasibility_score() - generate_balanced_charge_variants(): generates both K and R replacements at each S/T/N/Q position, giving the pipeline twice as many charge-enhanced options to rank by selectivity (K KD=-3.9 vs R KD=-4.5 differ in GRAVY; scoring picks the best one per seed profile) - generate_all_variants() now uses both new functions; old functions preserved for compat - SynthQC.aggregation_propensity_score: float [0,1] field added to SynthQC dataclass and to_dict(), providing continuous gradient information alongside aggregation_risk boolean - _max_hydrophobic_run() helper; _AGG_HYDROPHOBIC constant matching physchem.py 33 new tests; 1124 -> 1157 total passing --- Makefile | 2 +- docs/DISCOVERY_PREDICTION.md | 61 ++++-- .../generators/template_mutator.py | 111 +++++++++- src/openamp_foundry/qc/presynth_check.py | 8 +- tests/test_aggregation_safe_mutations.py | 204 ++++++++++++++++++ 5 files changed, 363 insertions(+), 23 deletions(-) create mode 100644 tests/test_aggregation_safe_mutations.py diff --git a/Makefile b/Makefile index a44b5eeb..3859b5de 100644 --- a/Makefile +++ b/Makefile @@ -20,7 +20,7 @@ lint: $(RUFF) check src tests scripts ci: lint test - @echo "CI passed: lint + 1124-test suite green" + @echo "CI passed: lint + 1157-test suite green" bench-leakage: PYTHONPATH=src $(PYTHON) -m openamp_foundry.cli bench leakage \ diff --git a/docs/DISCOVERY_PREDICTION.md b/docs/DISCOVERY_PREDICTION.md index 9d25a1f7..cdda3de8 100644 --- a/docs/DISCOVERY_PREDICTION.md +++ b/docs/DISCOVERY_PREDICTION.md @@ -3,7 +3,7 @@ **Pipeline:** OpenAMP-Foundry v0.1.0 **Date:** 2026-06-28 (updated 2026-06-28) **Status:** Pre-synthesis scientific assessment — for expert review before ordering -**Completed improvements:** Serum stability scoring (PR #31/#32), Family diversity cap (PR #31), Reference set expansion 44→73 sequences (PR #33), Net charge pH 7.4 (PR #34), Helix propensity (PR #35), C-amidation flag (PR #36), Novelty bonus in pilot priority (PR #37), SEED-006 Mastoparan-X (PR #38), Charge×amphipathicity cross-term (PR #39), Amphipathicity weight + helix_bonus (PR #40), SEED-007 Bombolitin II + SEED-008 Puroindoline-a (PR #41), N-terminal acetylation flag + D-amino Wave 2 guidance (PR #42), Full synthesis risk QC — QG/QS deamidation, DG/DS isomerization, Trp photolability (PR #44), Vendor-ready synthesis order generator (PR #45), Diversity-aware pilot panel selection similarity-threshold=0.75 (PR #46), Selectivity proxy + HIGH_CYTOTOX_RISK flag — charge/GRAVY-based mammalian cytotoxicity risk detector (PR #47), selectivity_proxy routed into pilot_priority formula — low-cytotox-risk candidates gently demoted (PR #48), Elastase resistance (HNE 3-protease stability model) + aggregation propensity scoring (synthesis feasibility penalty) (PR #49) +**Completed improvements:** Serum stability scoring (PR #31/#32), Family diversity cap (PR #31), Reference set expansion 44→73 sequences (PR #33), Net charge pH 7.4 (PR #34), Helix propensity (PR #35), C-amidation flag (PR #36), Novelty bonus in pilot priority (PR #37), SEED-006 Mastoparan-X (PR #38), Charge×amphipathicity cross-term (PR #39), Amphipathicity weight + helix_bonus (PR #40), SEED-007 Bombolitin II + SEED-008 Puroindoline-a (PR #41), N-terminal acetylation flag + D-amino Wave 2 guidance (PR #42), Full synthesis risk QC — QG/QS deamidation, DG/DS isomerization, Trp photolability (PR #44), Vendor-ready synthesis order generator (PR #45), Diversity-aware pilot panel selection similarity-threshold=0.75 (PR #46), Selectivity proxy + HIGH_CYTOTOX_RISK flag — charge/GRAVY-based mammalian cytotoxicity risk detector (PR #47), selectivity_proxy routed into pilot_priority formula — low-cytotox-risk candidates gently demoted (PR #48), Elastase resistance (HNE 3-protease stability model) + aggregation propensity scoring (synthesis feasibility penalty) (PR #49), Aggregation-safe mutation generation + balanced K/R charge variants + SynthQC continuous aggregation score (PR #50) --- @@ -65,6 +65,21 @@ Key improvements since last assessment (PRs #39–#49): (proxy=0.30) are now demoted by 0.035 relative to a fully selective peer with equal ensemble score — they can still earn their max_per_seed slots if they score well on ensemble, but are no longer ranked identically to selective candidates with the same activity profile. +- **Aggregation-safe mutation generation + balanced K/R variants (PR #50):** Generator-level + improvements that make candidate generation consistent with the scoring and QC layers: + (a) `generate_aggregation_safe_double_variants()` filters out double substitutions that would + create hydrophobic runs ≥4 residues, preventing the generator from producing sequences that + are immediately penalised by `synthesis_feasibility_score()` (run risk detected and filtered + *before* synthesis ordering, not discovered after). (b) `generate_balanced_charge_variants()` + generates both K and R replacements at each polar position (S/T/N/Q), giving the scoring + pipeline twice as many charge-enhanced options to rank — K (KD=−3.9) vs R (KD=−4.5) differ + in GRAVY and trypsin sensitivity, and the best one for each seed's GRAVY profile can now be + selected automatically. (c) `SynthQC.aggregation_propensity_score` (float [0,1]) added to + the presynth QC dataclass and to_dict(), providing continuous gradient information alongside + the existing `aggregation_risk` boolean — useful for prioritising borderline candidates + (e.g. score 0.14 vs 0.0 distinguishes a run-of-4 from a truly safe sequence). + Code quality: closes the divergence between the QC boolean flag and the continuous scoring model. + - **Elastase resistance + aggregation propensity (PR #49):** Two new computational features addressing the top pipeline gaps identified in post-PR-48 audit: (a) `aggregation_propensity()` [0,1] — quantifies on-resin hydrophobic-run and beta-branched @@ -85,21 +100,22 @@ novel scaffold family in the pilot panel.** To be publishable as a significant advance in AMP discovery, a candidate must satisfy all of: -| Criterion | Threshold | Original P | After PRs #31–38 | After PRs #39–42 | After PRs #43–47 | After PR #48 | After PR #49 (current) | -|-----------|-----------|------------|------------------|-----------------|-----------------|--------------|----------------------| -| Synthesis success (HPLC ≥ 90% purity) | ≥ 90% purity | ~90% | ~90% | ~88% | **~89%** ✓ | **~89%** | **~90%** ✓ (aggregation model) | -| MIC vs ATCC reference strains | ≤ 32 μg/mL | ~55–65% | ~55–65% | ~60–70% | **~60–70%** | **~60–70%** | **~60–70%** (unchanged) | -| Excellent selectivity | TI > 10 | ~35–50% | ~35–50% | ~38–52% | **~40–55%** ✓ | **~41–56%** ✓ | **~41–56%** (unchanged) | -| Serum stability | t½ > 2 h | ~10–20% | ~25–40% | ~28–42% | **~28–42%** | **~28–42%** | **~29–44%** ✓ (elastase model) | -| Scaffold novelty | Not in APD3/DRAMP | ~10–15% | ~18–25% | ~25–35% | **~26–36%** ✓ | **~26–36%** | **~26–36%** (unchanged) | -| Potency vs MDR strains | MIC < 8 μg/mL | not tested | not tested | not tested | not tested | not tested | not tested (wet-lab only) | +| Criterion | Threshold | Original P | After PRs #31–38 | After PRs #39–42 | After PRs #43–47 | After PR #48 | After PR #49 | After PR #50 (current) | +|-----------|-----------|------------|------------------|-----------------|-----------------|--------------|--------------|----------------------| +| Synthesis success (HPLC ≥ 90% purity) | ≥ 90% purity | ~90% | ~90% | ~88% | **~89%** ✓ | **~89%** | **~90%** ✓ (agg model) | **~90%** ✓ (agg-safe gen) | +| MIC vs ATCC reference strains | ≤ 32 μg/mL | ~55–65% | ~55–65% | ~60–70% | **~60–70%** | **~60–70%** | **~60–70%** | **~60–70%** (unchanged) | +| Excellent selectivity | TI > 10 | ~35–50% | ~35–50% | ~38–52% | **~40–55%** ✓ | **~41–56%** ✓ | **~41–56%** | **~41–56%** (unchanged) | +| Serum stability | t½ > 2 h | ~10–20% | ~25–40% | ~28–42% | **~28–42%** | **~28–42%** | **~29–44%** ✓ | **~29–44%** (unchanged) | +| Scaffold novelty | Not in APD3/DRAMP | ~10–15% | ~18–25% | ~25–35% | **~26–36%** ✓ | **~26–36%** | **~26–36%** | **~26–36%** (unchanged) | +| Potency vs MDR strains | MIC < 8 μg/mL | not tested | not tested | not tested | not tested | not tested | not tested | not tested (wet-lab only) | **Combined probability of satisfying all criteria simultaneously (original):** ~5–12% **Combined probability after PRs #31–38:** ~16–35% **Combined probability after PRs #39–42:** ~22–42% **Combined probability after PRs #43–47:** ~24–45% **Combined probability after PR #48:** ~25–46% -**Combined probability after PR #49 (current):** **~27–47%** +**Combined probability after PR #49:** ~27–47% +**Combined probability after PR #50 (current):** **~27–47%** (generator consistency — no new scoring gates) **Methodology note:** Combined probability is computed as P(≥1 from 20) under an independent-candidate Poisson model: P_individual = P(S0) × P(S1) × P(S2) × P(S3) × P(S4). Using gate midpoints @@ -436,6 +452,17 @@ Despite the probability gap, the pipeline has done the following correctly: content are now correctly penalised, ensuring the pipeline's stability gate reflects in-vivo biology rather than only in-vitro serum dilution assay conditions. +14. **Generator–scorer–QC consistency (PR #50):** Three layers of the pipeline now use the same + aggregation threshold (VILMFW run ≥ 4) consistently: (a) `generate_aggregation_safe_double_variants()` + in the generator filters out unsafe mutations before they enter the candidate pool; (b) + `aggregation_propensity()` in the scorer penalises unsafe sequences that reach synthesis + feasibility scoring; (c) `aggregation_risk` boolean and the new `aggregation_propensity_score` + float in `SynthQC` report both to the wet-lab operator. All three layers use the same definition + of "aggregation-risky", removing any possible divergence between what the generator creates, + what the scorer penalises, and what the QC reports. Additionally, `generate_balanced_charge_variants()` + now generates both K and R charge-enhanced options for each polar position, letting the scoring + pipeline select the variant with the better GRAVY/selectivity profile automatically. + --- ## Highest-Probability Bets in the Current Panel @@ -482,19 +509,19 @@ Executing all four actions on the best Wave 1 hits would push the combined proba ## Summary Table: Probability by Gate -| Stage | Gate | Original | After PRs #31–38 | After PRs #39–42 | After PRs #43–47 | After PR #48 (current) | Primary limiting factor | -|-------|------|----------|-----------------|-----------------|-----------------|----------------------|------------------------| -| 0 | Synthesis success | ~90% | ~90% | ~88% | **~89%** ✓ | **~89%** | SEED-008 W-rich; PR #44 catches more risks early | -| 1 | MIC ≤ 32 μg/mL | ~55–65% | ~55–65% | ~60–70% | **~60–70%** | **~60–70%** | AUROC 0.811; 8 scaffold families | +| Stage | Gate | Original | After PRs #31–38 | After PRs #39–42 | After PRs #43–47 | After PRs #48–50 (current) | Primary limiting factor | +|-------|------|----------|-----------------|-----------------|-----------------|--------------------------|------------------------| +| 0 | Synthesis success | ~90% | ~90% | ~88% | **~89%** ✓ | **~90%** ✓ (agg model + agg-safe gen) | SEED-008 W-rich; all aggregation risks now modelled | +| 1 | MIC ≤ 32 μg/mL | ~55–65% | ~55–65% | ~60–70% | **~60–70%** | **~60–70%** | AUROC 0.814; 8 scaffold families | | 2 | TI > 10 (selectivity) | ~35–50% | ~35–50% | ~38–52% | **~40–55%** ✓ | **~41–56%** ✓ | sel_proxy in pilot ranking demotes SEED-004 | -| 3 | t½ > 2 h (serum) | ~10–20% | ~25–40% | ~28–42% | **~28–42%** | **~28–42%** | Wave 2 D-amino plan machine-readable | +| 3 | t½ > 2 h (serum) | ~10–20% | ~25–40% | ~28–42% | **~28–42%** | **~29–44%** ✓ (3-protease model) | Wave 2 D-amino plan machine-readable | | 4 | Scaffold novelty | ~10–15% | ~18–25% | ~25–35% | **~26–36%** ✓ | **~26–36%** | Diversity filter removes cross-seed near-dups | -| All | "Breaking news" hit | ~5–12% | ~16–35% | ~22–42% | ~24–45% | **~25–46%** ✓ | MDR strains + Wave 2 D-amino = path to 50%+ | +| All | "Breaking news" hit | ~5–12% | ~16–35% | ~22–42% | ~24–45% | **~27–47%** ✓ | MDR strains + Wave 2 D-amino = path to 50%+ | **Probability of ≥1 active AMP from pilot panel (Stage 1 only):** ~90–97% (Probability of zero active from 20 candidates with ~65% individual hit rate ≈ 3–10%) -**Probability of ≥1 candidate satisfying ALL gates (current panel, PRs #31–#48):** ~25–46% +**Probability of ≥1 candidate satisfying ALL gates (current panel, PRs #31–#50):** ~27–47% --- diff --git a/src/openamp_foundry/generators/template_mutator.py b/src/openamp_foundry/generators/template_mutator.py index 91d0b23d..bf8c9668 100644 --- a/src/openamp_foundry/generators/template_mutator.py +++ b/src/openamp_foundry/generators/template_mutator.py @@ -14,6 +14,8 @@ - Preserve backbone length (no insertions/deletions in this generator) - Never target charged residues for hydrophobic substitution (or vice versa) - Use deterministic seeding so output is reproducible + - Aggregation-safe double substitutions avoid creating hydrophobic runs ≥4 + (matching the QC HYDROPHOBIC_RUN_RE threshold used in presynth_check.py) This generator does NOT claim to produce biologically active peptides. All generated sequences must pass through the full pipeline safety filters @@ -36,6 +38,10 @@ frozenset("C"), # cysteine — singleton; substitutions restricted ] +# Residues that drive aggregation / on-resin clumping during SPPS. +# Must match AGG_HYDROPHOBIC in physchem.py and the regex set [VILMFW]{4,} in presynth_check.py. +_AGG_HYDROPHOBIC: frozenset[str] = frozenset("VILMFW") + def _conservative_substitutes(aa: str) -> list[str]: """Return amino acids that can conservatively substitute for `aa`.""" @@ -45,6 +51,19 @@ def _conservative_substitutes(aa: str) -> list[str]: return [] +def _max_hydrophobic_run(sequence: str) -> int: + """Return the length of the longest consecutive _AGG_HYDROPHOBIC run in sequence.""" + max_run = 0 + current = 0 + for aa in sequence: + if aa in _AGG_HYDROPHOBIC: + current += 1 + max_run = max(max_run, current) + else: + current = 0 + return max_run + + def generate_single_substitution_variants(sequence: str) -> list[str]: """Generate all single-substitution conservative variants of a template. @@ -99,6 +118,55 @@ def generate_double_substitution_variants( return variants +def generate_aggregation_safe_double_variants( + sequence: str, + n_samples: int = 20, + seed: int = 42, + max_run: int = 4, +) -> list[str]: + """Like generate_double_substitution_variants but rejects variants with a + consecutive _AGG_HYDROPHOBIC run >= max_run. + + Default max_run=4 matches the QC HYDROPHOBIC_RUN_RE threshold ([VILMFW]{4,}) + and the aggregation_propensity() run component in physchem.py. This prevents + the generator from producing sequences that the synthesis scoring pipeline + would penalise for on-resin aggregation. + + Returns up to n_samples unique aggregation-safe variants. + """ + rng = random.Random(seed) + substitutable = [ + (i, _conservative_substitutes(aa)) + for i, aa in enumerate(sequence) + if _conservative_substitutes(aa) + ] + if len(substitutable) < 2: + return [] + + seen: set[str] = set() + variants = [] + attempts = 0 + # Larger attempt budget: some hydrophobic seeds need many tries to find safe pairs. + while len(variants) < n_samples and attempts < n_samples * 50: + attempts += 1 + pos_a, subs_a = rng.choice(substitutable) + pos_b, subs_b = rng.choice(substitutable) + if pos_a == pos_b: + continue + sub_a = rng.choice(subs_a) + sub_b = rng.choice(subs_b) + seq_list = list(sequence) + seq_list[pos_a] = sub_a + seq_list[pos_b] = sub_b + new_seq = "".join(seq_list) + if _max_hydrophobic_run(new_seq) >= max_run: + continue + if new_seq not in seen: + seen.add(new_seq) + variants.append(new_seq) + return variants + + def generate_charge_enhanced_variants( sequence: str, n_samples: int = 10, @@ -127,6 +195,36 @@ def generate_charge_enhanced_variants( return variants +def generate_balanced_charge_variants( + sequence: str, + n_samples: int = 10, +) -> list[str]: + """Generate both K and R replacements for each polar (S/T/N/Q) position. + + Unlike generate_charge_enhanced_variants(), which randomly picks K or R, + this function generates one K and one R variant per targetable position. + The scoring pipeline can then rank both options and select the better one + based on GRAVY / selectivity_proxy trade-offs: + - K (KD = -3.9): slightly less hydrophilic than R, slightly lower trypsin rate + - R (KD = -4.5): more hydrophilic, marginally higher GRAVY improvement + + Returns up to 2 × n_samples unique variants (K and R per position). + """ + targetable = [i for i, aa in enumerate(sequence) if aa in "STNQ"] + if not targetable: + return [] + + seen: set[str] = set() + variants = [] + for i in targetable[:n_samples]: + for replacement in ("K", "R"): + new_seq = sequence[:i] + replacement + sequence[i + 1:] + if new_seq not in seen: + seen.add(new_seq) + variants.append(new_seq) + return variants + + def generate_all_variants( seed_sequence: str, n_double: int = 15, @@ -135,13 +233,18 @@ def generate_all_variants( ) -> list[str]: """Generate all candidate variants for a template sequence. - Combines single substitutions, double substitutions, and charge-enhanced variants. - Deduplicates and removes the seed itself. + Combines single substitutions, aggregation-safe double substitutions, and + balanced (K+R) charge-enhanced variants. Deduplicates and removes the seed. + + Double substitutions use generate_aggregation_safe_double_variants() to avoid + creating hydrophobic runs ≥4 that would be penalised by synthesis scoring. + Charge enhancements use generate_balanced_charge_variants() to produce both + K and R options at each polar position, letting the scoring pipeline rank them. """ variants: set[str] = set() variants.update(generate_single_substitution_variants(seed_sequence)) - variants.update(generate_double_substitution_variants(seed_sequence, n_double, seed)) - variants.update(generate_charge_enhanced_variants(seed_sequence, n_charge_enhance, seed)) + variants.update(generate_aggregation_safe_double_variants(seed_sequence, n_double, seed)) + variants.update(generate_balanced_charge_variants(seed_sequence, n_charge_enhance)) variants.discard(seed_sequence) return sorted(variants) diff --git a/src/openamp_foundry/qc/presynth_check.py b/src/openamp_foundry/qc/presynth_check.py index a1651480..3a919cb1 100644 --- a/src/openamp_foundry/qc/presynth_check.py +++ b/src/openamp_foundry/qc/presynth_check.py @@ -8,6 +8,7 @@ import re from dataclasses import dataclass, field +from openamp_foundry.features.physchem import aggregation_propensity as _agg_propensity from openamp_foundry.features.physchem import net_charge_at_ph74 as _charge_at_ph74 from openamp_foundry.features.physchem import selectivity_proxy as _sel_proxy from openamp_foundry.scoring.boman import gravy_score as _gravy @@ -108,7 +109,8 @@ class SynthQC: methionine_count: int = 0 has_oxidation_risk: bool = False # C or M present hydrophobic_run: str = "" # longest hydrophobic run (empty = none) - aggregation_risk: bool = False # run ≥ 4 hydrophobic AA + aggregation_risk: bool = False # run ≥ 4 hydrophobic AA (boolean gate) + aggregation_propensity_score: float = 0.0 # continuous [0,1] from physchem.aggregation_propensity() # Proteolytic vulnerabilities trypsin_sites: list[int] = field(default_factory=list) # positions (0-based) @@ -161,6 +163,7 @@ def to_dict(self) -> dict: "methionine_count": self.methionine_count, "oxidation_risk": self.has_oxidation_risk, "aggregation_risk": self.aggregation_risk, + "aggregation_propensity_score": self.aggregation_propensity_score, "hydrophobic_run": self.hydrophobic_run, "trypsin_sites": self.trypsin_sites, "chymotrypsin_sites": self.chymotrypsin_sites, @@ -205,6 +208,9 @@ def check_sequence(candidate_id: str, seq: str, mu_h: float = 0.0) -> SynthQC: if runs: qc.hydrophobic_run = max(runs, key=len) qc.aggregation_risk = True + # Continuous aggregation score (two-component: run length + beta-branched density). + # Provides gradient information beyond the binary aggregation_risk flag. + qc.aggregation_propensity_score = _agg_propensity(seq) # Proteolytic sites (interior only — terminal K/R are typical, less risky). # _TRYPSIN_RE already requires a following character via (?=.), so the diff --git a/tests/test_aggregation_safe_mutations.py b/tests/test_aggregation_safe_mutations.py new file mode 100644 index 00000000..b9fa187f --- /dev/null +++ b/tests/test_aggregation_safe_mutations.py @@ -0,0 +1,204 @@ +"""Tests for aggregation-safe mutation generation and balanced charge variants. + +Verifies: + - _max_hydrophobic_run() counts consecutive VILMFW residues correctly + - generate_aggregation_safe_double_variants() filters out high-run sequences + - generate_balanced_charge_variants() produces both K and R for each polar position + - SynthQC.aggregation_propensity_score is populated and consistent with aggregation_risk +""" +from __future__ import annotations + +from openamp_foundry.generators.template_mutator import ( + _AGG_HYDROPHOBIC, + _max_hydrophobic_run, + generate_aggregation_safe_double_variants, + generate_balanced_charge_variants, + generate_all_variants, +) +from openamp_foundry.qc.presynth_check import check_sequence + + +SEED = "KWKLFKKIGAVLKVL" # typical AMP-like template, max run = 2 + + +class TestMaxHydrophobicRun: + def test_empty_string_returns_zero(self): + assert _max_hydrophobic_run("") == 0 + + def test_no_agg_residues_returns_zero(self): + # KDKE: K, D, K, E — none in VILMFW + assert _max_hydrophobic_run("KDKE") == 0 + + def test_single_agg_residue_returns_one(self): + assert _max_hydrophobic_run("KVK") == 1 + + def test_run_of_four_detected(self): + # KVLLLK: V,L,L,L at pos 1-4 → run = 4 + assert _max_hydrophobic_run("KVLLLK") == 4 + + def test_run_of_eight_detected(self): + # KVLLLLLLLK: 8 consecutive VILMFW residues + assert _max_hydrophobic_run("KVLLLLLLLK") == 8 + + def test_run_broken_by_non_agg_residue(self): + # VLK LV: run of 2, break at K, run of 2 → max = 2 + assert _max_hydrophobic_run("VLKLV") == 2 + + def test_agg_hydrophobic_constant_matches_expected_set(self): + # Sanity: the module constant agrees with the documented set VILMFW + assert _AGG_HYDROPHOBIC == frozenset("VILMFW") + + def test_glycine_alanine_not_in_agg_set(self): + # G and A are NOT aggregation-driving; VILMFW is the threshold set + assert "G" not in _AGG_HYDROPHOBIC + assert "A" not in _AGG_HYDROPHOBIC + + def test_aromatic_in_agg_set(self): + # F and W are included (aromatic → aggregation-prone) + assert "F" in _AGG_HYDROPHOBIC + assert "W" in _AGG_HYDROPHOBIC + + +class TestAggregationSafeDoubleVariants: + def test_no_variant_has_run_ge_threshold(self): + # All output variants must have max hydrophobic run < 4 + variants = generate_aggregation_safe_double_variants(SEED, n_samples=30, seed=42) + for v in variants: + run = _max_hydrophobic_run(v) + assert run < 4, f"Variant {v!r} has hydrophobic run {run} >= 4" + + def test_variants_same_length_as_seed(self): + variants = generate_aggregation_safe_double_variants(SEED, n_samples=15, seed=42) + for v in variants: + assert len(v) == len(SEED), f"Length mismatch: {v!r}" + + def test_seed_not_in_output(self): + variants = generate_aggregation_safe_double_variants(SEED, n_samples=20, seed=42) + assert SEED not in variants + + def test_differs_by_at_most_two_positions(self): + variants = generate_aggregation_safe_double_variants(SEED, n_samples=15, seed=42) + for v in variants: + diffs = sum(a != b for a, b in zip(v, SEED)) + assert diffs <= 2, f"Expected ≤2 diffs, got {diffs}: {v!r}" + + def test_no_duplicate_output(self): + variants = generate_aggregation_safe_double_variants(SEED, n_samples=20, seed=42) + assert len(variants) == len(set(variants)) + + def test_deterministic_with_same_seed(self): + v1 = generate_aggregation_safe_double_variants(SEED, n_samples=10, seed=99) + v2 = generate_aggregation_safe_double_variants(SEED, n_samples=10, seed=99) + assert v1 == v2 + + def test_custom_max_run_threshold(self): + # With max_run=3, even a run of 3 is rejected + variants = generate_aggregation_safe_double_variants(SEED, n_samples=20, seed=42, max_run=3) + for v in variants: + assert _max_hydrophobic_run(v) < 3, f"Run >= 3 in {v!r}" + + def test_produces_output_from_typical_amp_seed(self): + variants = generate_aggregation_safe_double_variants(SEED, n_samples=10, seed=42) + assert len(variants) >= 1, "Should produce at least one safe double-sub variant" + + def test_filtering_hydrophobic_seed(self): + # Seed with run=3 (one below threshold): safe doubles should not extend it to 4+ + seed = "KKVLLK" # VLL = run of 3 + variants = generate_aggregation_safe_double_variants(seed, n_samples=30, seed=42, max_run=4) + for v in variants: + assert _max_hydrophobic_run(v) < 4, f"Created unsafe run in {v!r}" + + +class TestBalancedChargeVariants: + def test_generates_both_k_and_r_per_position(self): + # KSSK: S at positions 1 and 2 → expect K and R variants for each + seq = "KSSK" + variants = generate_balanced_charge_variants(seq, n_samples=5) + replacements = set(v[1] for v in variants if v[0] == "K" and v[3] == "K") + # Both K and R should appear as replacements for at least one S + assert "K" in replacements or any(v[1] == "K" for v in variants) + assert "R" in replacements or any(v[1] == "R" for v in variants) + + def test_all_variants_same_length(self): + variants = generate_balanced_charge_variants(SEED, n_samples=5) + for v in variants: + assert len(v) == len(SEED) + + def test_no_variants_when_no_polar_residues(self): + # All charged — no S/T/N/Q to replace + assert generate_balanced_charge_variants("KRKRKR") == [] + + def test_replacements_are_k_or_r_only(self): + seq = "KWKSFKKIGAVLKVL" # S at position 3 + variants = generate_balanced_charge_variants(seq, n_samples=3) + for v in variants: + diff_positions = [i for i, (a, b) in enumerate(zip(v, seq)) if a != b] + assert len(diff_positions) == 1 + pos = diff_positions[0] + assert v[pos] in "KR", f"Replacement is {v[pos]!r}, expected K or R" + + def test_seed_not_in_variants(self): + seq = "KWKSFKKIGAVLKVL" + variants = generate_balanced_charge_variants(seq) + assert seq not in variants + + def test_no_duplicates(self): + variants = generate_balanced_charge_variants(SEED, n_samples=10) + assert len(variants) == len(set(variants)) + + +class TestGenerateAllVariantsUpdated: + def test_no_variant_in_all_variants_has_run_ge_4(self): + # generate_all_variants now uses aggregation_safe_double_variants + variants = generate_all_variants(SEED, n_double=20, n_charge_enhance=8) + for v in variants: + assert _max_hydrophobic_run(v) < 4, f"{v!r} has run >= 4" + + def test_all_variants_includes_both_k_and_r_charge_variants(self): + # KWKSFKKIGAVLKVL has S at position 3; should produce S→K and S→R variants + seq = "KWKSFKKIGAVLKVL" + variants = set(generate_all_variants(seq, n_charge_enhance=5)) + # K and R replacement at position 3 + k_variant = seq[:3] + "K" + seq[4:] + r_variant = seq[:3] + "R" + seq[4:] + assert k_variant in variants, f"K-charge variant {k_variant!r} missing" + assert r_variant in variants, f"R-charge variant {r_variant!r} missing" + + +class TestSynthQCAggregationPropensityScore: + def test_field_exists_on_synthqc(self): + qc = check_sequence("test", "KWKLFKKIGAVLKVL") + assert hasattr(qc, "aggregation_propensity_score") + + def test_field_in_to_dict(self): + qc = check_sequence("test", "KWKLFKKIGAVLKVL") + d = qc.to_dict() + assert "aggregation_propensity_score" in d + + def test_score_is_unit_interval(self): + for seq in ["KKKKKK", "IIIIIIII", "KWKLFKKIGAVLKVL", "GIGKFLHSAKKFGKAFVGEIMNS"]: + qc = check_sequence("test", seq) + s = qc.aggregation_propensity_score + assert 0.0 <= s <= 1.0, f"score={s} out of [0,1] for {seq}" + + def test_high_run_peptide_has_high_score(self): + # IIIIIIIIII: run of 10 → aggregation_propensity = 1.0 + qc = check_sequence("high_agg", "IIIIIIIIII") + assert qc.aggregation_propensity_score > 0.5 + assert qc.aggregation_risk is True + + def test_polar_peptide_has_zero_score(self): + qc = check_sequence("polar", "KRKRKRKR") + assert qc.aggregation_propensity_score == 0.0 + assert qc.aggregation_risk is False + + def test_score_consistent_with_boolean_flag(self): + # When aggregation_risk is True (run >= 4), score should be > 0 + qc_risky = check_sequence("risky", "KVLLLKVLLLK") + assert qc_risky.aggregation_risk is True + assert qc_risky.aggregation_propensity_score > 0.0 + + def test_score_zero_when_no_run(self): + # Pure cationic: no VILMFW residues → score = 0 + qc = check_sequence("nocr", "KKKKKKKKK") + assert qc.aggregation_propensity_score == 0.0 From 4cf7a88bf270c38948c3bf6a08793ed4dee81a8d Mon Sep 17 00:00:00 2001 From: j Date: Sun, 28 Jun 2026 14:55:40 +0700 Subject: [PATCH 2/2] fix: address code review issues from PR #50 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HIGH: Single-substitution variants now filtered in generate_all_variants() with _max_hydrophobic_run(v) < 4, same as double-substitution path. A seed with run=3 and adjacent A/Y (conservative aliphatic group members) could produce run=4 singles; added test with KVLLAK borderline seed to verify the filter catches KVLLLK. MEDIUM: Import _AGG_HYDROPHOBIC from physchem.AGG_HYDROPHOBIC instead of duplicating the constant — single source of truth, cross-module consistency guaranteed by the import rather than a manual comment. Fixed aggregation_propensity() docstring: "Ramp: 0.0 at run=4" corrected to "0.20 at run=4" (formula (max_run-3)/5.0 gives 0.20, not 0.0). LOW: generate_balanced_charge_variants() n_samples parameter renamed to n_positions for API clarity (n_samples caps total output in other generators; here it caps positions targeted, yielding up to 2×n_positions sequences). Added deprecation note to generate_charge_enhanced_variants(). Fixed trivial test assertion in test_generates_both_k_and_r_per_position to use direct variant lookup rather than fallback any() clauses that always passed. 1157 → 1158 tests --- Makefile | 2 +- src/openamp_foundry/features/physchem.py | 2 +- .../generators/template_mutator.py | 29 +++++++++++++---- tests/test_aggregation_safe_mutations.py | 32 +++++++++++++------ 4 files changed, 46 insertions(+), 19 deletions(-) diff --git a/Makefile b/Makefile index 3859b5de..d3487f50 100644 --- a/Makefile +++ b/Makefile @@ -20,7 +20,7 @@ lint: $(RUFF) check src tests scripts ci: lint test - @echo "CI passed: lint + 1157-test suite green" + @echo "CI passed: lint + 1158-test suite green" bench-leakage: PYTHONPATH=src $(PYTHON) -m openamp_foundry.cli bench leakage \ diff --git a/src/openamp_foundry/features/physchem.py b/src/openamp_foundry/features/physchem.py index 88e33302..00b7245c 100644 --- a/src/openamp_foundry/features/physchem.py +++ b/src/openamp_foundry/features/physchem.py @@ -193,7 +193,7 @@ def aggregation_propensity(sequence: str) -> float: 1. Hydrophobic run length: consecutive residues from {V,I,L,M,F,W} ≥ 4 drive self-association during SPPS, solubilisation, and assay buffers. Risk onset at run ≥ 4 (same threshold as QC HYDROPHOBIC_RUN_RE flag; same - residue set AGG_HYDROPHOBIC). Ramp: 0.0 at run=4 → 1.0 at run ≥ 8. + residue set AGG_HYDROPHOBIC). Ramp: 0.0 at run < 4, 0.20 at run=4, 1.0 at run ≥ 8. 2. Beta-branched residue density (Val, Ile, Thr) > 20% promotes β-strand over α-helix and intermolecular β-sheet aggregation in concentrated solutions. diff --git a/src/openamp_foundry/generators/template_mutator.py b/src/openamp_foundry/generators/template_mutator.py index bf8c9668..81012e32 100644 --- a/src/openamp_foundry/generators/template_mutator.py +++ b/src/openamp_foundry/generators/template_mutator.py @@ -25,6 +25,8 @@ import random +from openamp_foundry.features.physchem import AGG_HYDROPHOBIC as _AGG_HYDROPHOBIC + CANONICAL_AA = "ACDEFGHIKLMNPQRSTVWY" # Conservative substitution groups: within-group swaps preserve physicochemical character @@ -38,9 +40,7 @@ frozenset("C"), # cysteine — singleton; substitutions restricted ] -# Residues that drive aggregation / on-resin clumping during SPPS. -# Must match AGG_HYDROPHOBIC in physchem.py and the regex set [VILMFW]{4,} in presynth_check.py. -_AGG_HYDROPHOBIC: frozenset[str] = frozenset("VILMFW") +# _AGG_HYDROPHOBIC is imported from physchem.py (single source of truth for the VILMFW residue set). def _conservative_substitutes(aa: str) -> list[str]: @@ -176,6 +176,10 @@ def generate_charge_enhanced_variants( Targets positions occupied by non-charged hydrophilic residues (S, T, N, Q) and replaces with K (higher charge density → better activity likeness). + + Deprecated: generate_all_variants() now calls generate_balanced_charge_variants() + which generates both K and R for each polar position. This function is retained + for backward compatibility (used by existing tests and any external callers). """ rng = random.Random(seed) targetable = [ @@ -197,7 +201,7 @@ def generate_charge_enhanced_variants( def generate_balanced_charge_variants( sequence: str, - n_samples: int = 10, + n_positions: int = 10, ) -> list[str]: """Generate both K and R replacements for each polar (S/T/N/Q) position. @@ -208,7 +212,11 @@ def generate_balanced_charge_variants( - K (KD = -3.9): slightly less hydrophilic than R, slightly lower trypsin rate - R (KD = -4.5): more hydrophilic, marginally higher GRAVY improvement - Returns up to 2 × n_samples unique variants (K and R per position). + Args: + n_positions: maximum number of polar positions to target. Returns up to + 2 × n_positions unique variants (one K variant and one R variant per position). + Named n_positions (not n_samples) to avoid confusion with other generator + functions where n_samples bounds the total output count directly. """ targetable = [i for i, aa in enumerate(sequence) if aa in "STNQ"] if not targetable: @@ -216,7 +224,7 @@ def generate_balanced_charge_variants( seen: set[str] = set() variants = [] - for i in targetable[:n_samples]: + for i in targetable[:n_positions]: for replacement in ("K", "R"): new_seq = sequence[:i] + replacement + sequence[i + 1:] if new_seq not in seen: @@ -238,11 +246,18 @@ def generate_all_variants( Double substitutions use generate_aggregation_safe_double_variants() to avoid creating hydrophobic runs ≥4 that would be penalised by synthesis scoring. + Single substitutions are also filtered for the same threshold — a conservative swap + of an adjacent residue can extend a near-threshold run into aggregation territory. Charge enhancements use generate_balanced_charge_variants() to produce both K and R options at each polar position, letting the scoring pipeline rank them. """ variants: set[str] = set() - variants.update(generate_single_substitution_variants(seed_sequence)) + # Single subs are filtered: a conservative A→L substitution adjacent to a run-of-3 + # can create run-of-4, which would be flagged by synthesis scoring and QC. + variants.update( + v for v in generate_single_substitution_variants(seed_sequence) + if _max_hydrophobic_run(v) < 4 + ) variants.update(generate_aggregation_safe_double_variants(seed_sequence, n_double, seed)) variants.update(generate_balanced_charge_variants(seed_sequence, n_charge_enhance)) variants.discard(seed_sequence) diff --git a/tests/test_aggregation_safe_mutations.py b/tests/test_aggregation_safe_mutations.py index b9fa187f..bf6f1c9a 100644 --- a/tests/test_aggregation_safe_mutations.py +++ b/tests/test_aggregation_safe_mutations.py @@ -111,16 +111,18 @@ def test_filtering_hydrophobic_seed(self): class TestBalancedChargeVariants: def test_generates_both_k_and_r_per_position(self): - # KSSK: S at positions 1 and 2 → expect K and R variants for each + # KSSK: S at positions 1 and 2 → expect K and R variants for both S positions seq = "KSSK" - variants = generate_balanced_charge_variants(seq, n_samples=5) - replacements = set(v[1] for v in variants if v[0] == "K" and v[3] == "K") - # Both K and R should appear as replacements for at least one S - assert "K" in replacements or any(v[1] == "K" for v in variants) - assert "R" in replacements or any(v[1] == "R" for v in variants) + variant_set = set(generate_balanced_charge_variants(seq, n_positions=5)) + # S at index 1: expect KKSK and KRSK + assert "KKSK" in variant_set, "Missing S1→K variant" + assert "KRSK" in variant_set, "Missing S1→R variant" + # S at index 2: "KS" + replacement + "K" + assert "KSKK" in variant_set, "Missing S2→K variant" + assert "KSRK" in variant_set, "Missing S2→R variant" def test_all_variants_same_length(self): - variants = generate_balanced_charge_variants(SEED, n_samples=5) + variants = generate_balanced_charge_variants(SEED, n_positions=5) for v in variants: assert len(v) == len(SEED) @@ -130,7 +132,7 @@ def test_no_variants_when_no_polar_residues(self): def test_replacements_are_k_or_r_only(self): seq = "KWKSFKKIGAVLKVL" # S at position 3 - variants = generate_balanced_charge_variants(seq, n_samples=3) + variants = generate_balanced_charge_variants(seq, n_positions=3) for v in variants: diff_positions = [i for i, (a, b) in enumerate(zip(v, seq)) if a != b] assert len(diff_positions) == 1 @@ -143,17 +145,27 @@ def test_seed_not_in_variants(self): assert seq not in variants def test_no_duplicates(self): - variants = generate_balanced_charge_variants(SEED, n_samples=10) + variants = generate_balanced_charge_variants(SEED, n_positions=10) assert len(variants) == len(set(variants)) class TestGenerateAllVariantsUpdated: def test_no_variant_in_all_variants_has_run_ge_4(self): - # generate_all_variants now uses aggregation_safe_double_variants + # generate_all_variants filters both single- and double-substitution variants variants = generate_all_variants(SEED, n_double=20, n_charge_enhance=8) for v in variants: assert _max_hydrophobic_run(v) < 4, f"{v!r} has run >= 4" + def test_borderline_seed_single_sub_filtered(self): + # "KVLLAK": max run = 3 (VLL). Single sub A→L at pos 4 gives "KVLLLK" = run 4. + # generate_all_variants must NOT include "KVLLLK" (filtered by single-sub guard). + seed = "KVLLAK" + variants = set(generate_all_variants(seed, n_double=10, n_charge_enhance=3)) + assert "KVLLLK" not in variants, "KVLLLK (run=4) should be filtered from single subs" + # All returned variants must respect the threshold + for v in variants: + assert _max_hydrophobic_run(v) < 4, f"{v!r} has run >= 4 from borderline seed" + def test_all_variants_includes_both_k_and_r_charge_variants(self): # KWKSFKKIGAVLKVL has S at position 3; should produce S→K and S→R variants seq = "KWKSFKKIGAVLKVL"