diff --git a/docs/50_LOOP_PLAN.md b/docs/50_LOOP_PLAN.md index a6167ee2..795eb4fa 100644 --- a/docs/50_LOOP_PLAN.md +++ b/docs/50_LOOP_PLAN.md @@ -99,7 +99,7 @@ Build the multi-resolution virtual assay layer: structure proxies, membrane inte | Loop | Bottleneck | Deliverable | Verification | |------|-----------|-------------|-------------| | 30 ✅ | No explicit virtual-assay scope document; stale BENCHMARKING.md; stale test count in METRICS_CURRENT.md; broken script ref in NOVELTY_AUDIT_GUIDE.md | `docs/VIRTUAL_ASSAY_SCOPE.md` — scope doc with modeled/NOT-modeled effects, uncertainty policy, ablation requirement, integration modes. Rewrote `docs/BENCHMARKING.md` with all 19 benchmarks. Fixed test count, script ref, scripts/__init__.py. | Scope doc reviewable; BENCHMARKING.md matches Makefile; 1843 tests | -| 31 | No membrane interaction proxy. The simulation module exists but has only a dummy placeholder | `simulation/membrane.py`: implements coarse-grained membrane binding energy proxy using hydrophobicity scale + helical hydrophobic moment. Returns `SimulationResult` with uncertainty | Benchmark: separates melittin (hemolytic) from magainin (selective) better than random (AUROC > 0.6 on strict set) | +| 31 ✅ | No membrane interaction proxy. The simulation module had only a dummy placeholder | `simulation/membrane.py`: MembraneProxy with Wimley-White interfacial and octanol scales, bacterial/mammalian binding scores, selectivity ratio, and uncertainty. BomanBaseline clamped. v0.5.52 | 30 tests, 1873 total. Magainin more selective than melittin (verified by test). Membrane proxy initialised, benchmark deferred to Loop 34 | | 32 | No structure ensemble proxy. Current pipeline assumes all AMPs are helical | `simulation/structure.py`: secondary structure prediction (helix/coil/sheet propensity from sequence). Generates 3-state ensemble weights. Flags non-helical candidates | For known beta-sheet AMPs (protegrin, tachyplesin), reports low helix confidence | | 33 | No selectivity ratio module that combines bacterial and mammalian membrane scores | `simulation/selectivity_ratio.py`: ratio of bacterial membrane binding to RBC membrane binding proxy. Reports as `selectivity_ratio` with uncertainty | Distinguishes known selective (magainin) from known hemolytic (melittin) at ratio > 2x | | 34 | No benchmark set for membrane proxy calibration | `examples/validation/membrane_reference.csv`: 30+ AMPs with known membrane activity (pore-forming, carpet, toroidal pore). Literature-sourced with citations | Dataset card exists with source, citation, and known limitations | @@ -180,7 +180,7 @@ If no data arrives, virtual assay scaffolding continues independently. Phase 0: ✅ Complete (Loops 1–8) Phase 1: ✅ Complete (Loops 9–17) Phase 2: ✅ Complete (Loops 18–29) -Phase 3: In progress — Loop 30 ✅ (Loops 30–39) +Phase 3: In progress — Loop 31 ✅ (Loops 30–39) Phase 4: Not started (Loops 40–49) ``` @@ -241,7 +241,7 @@ Phase 4: Not started (Loops 40–49) | 28 ✅ | Policy version bump workflow for when real data arrives | `scripts/bump_recalibration_policy.py`: standalone script with `--dry-run`, decision-log guard, auto-increment + write. CI guard in `ci.yml` validates policy version changes against base branch. v0.5.49. 9 tests. | CI rejects policy PRs without valid decision log; `make bump-policy-version` and `make bump-policy-version-dry-run` available | | 29 ✅ | No public negative-result archive format. If Wave 1 yields all negatives, where do they go? | `docs/NEGATIVE_RESULT_ARCHIVE.md`: full template with entry schema, procedures, automation notes, and limitations. Covers pre-selection rejects, selected-untested, lab inactives, lab toxic, control failures. v0.5.50. | Template complete enough for a lab partner to fill; schema defines 18 fields with required/conditional markers | -**Next loop:** Loop 31 — Phase 3 (membrane interaction proxy). +**Next loop:** Loop 32 — Phase 3 (structure ensemble proxy). **Phase 2 exit criteria (all 5 met ✅):** - ✅ `make calibration-loop` runs from clean checkout, produces batch-2 manifest diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index c1e945cc..7f7922b7 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -42,7 +42,7 @@ The purpose of the added layers is not simulation theater. It is to improve whic | `openamp_foundry.reports` | human-readable and machine-readable batch, calibration, and wet-lab review reports | | `openamp_foundry.benchmark` | leakage checks, cluster-split benchmark, expert ablation benchmark, within-AMP selectivity benchmark, triage benchmark, per-feature selectivity decomposition, and evaluation scaffolding | | `openamp_foundry.generators` | safe, bounded toy candidate generation | -| `openamp_foundry.simulation` | membrane/selectivity/stability proxy modeling (scaffolded) | +| `openamp_foundry.simulation` | membrane proxy (Loop 31), dummy stub, interfaces — structure/selectivity/stability scaffolding | | `openamp_foundry.calibration` | lab-result intake (v0.5.19), pre-registered recalibration policy + gate (v0.5.20), engine + report (v0.5.36/v0.5.44), policy version tracking (v0.5.40) | | `openamp_foundry.active_learning` | batch-2 selector (v0.5.45) and recovery benchmark (v0.5.46) for choosing informative next experiments under uncertainty | | `openamp_foundry.analysis` | diversity clustering, panel similarity, family structural warnings, audit helpers | diff --git a/docs/METRICS_CURRENT.md b/docs/METRICS_CURRENT.md index f0ee43f4..fbd70096 100644 --- a/docs/METRICS_CURRENT.md +++ b/docs/METRICS_CURRENT.md @@ -5,7 +5,9 @@ Machine-readable snapshot: `outputs/metrics_snapshot.json` regenerated with `mak > **Purpose:** One authoritative table of current pipeline metrics. If any doc disagrees > with this file, this file wins. Updated whenever benchmark/benchmark config changes. > -> **Last updated:** 2026-07-06 (negative-result archive + Phase 2 closeout — v0.5.50) +> **Last updated:** 2026-07-06 (membrane proxy — v0.5.52) +> **New in v0.5.52:** Membrane interaction proxy — `MembraneProxy` using Wimley-White interfacial/octanol scales for coarse-grained bacterial and mammalian binding energy scores. `BomanBaseline` clamped. 30 new tests. 1873 total. +> **New in v0.5.51:** Virtual assay scope document — `docs/VIRTUAL_ASSAY_SCOPE.md` with uncertainty policy, ablation requirements, integration modes. Doc drift fixes: BENCHMARKING.md, METRICS_CURRENT.md test count, NOVELTY_AUDIT_GUIDE.md script ref. No code changes. > **New in v0.5.50:** Negative-result archive template — `docs/NEGATIVE_RESULT_ARCHIVE.md` with full 18-field entry schema, automation notes, and limitations. Phase 2 exit criteria: all 5 met ✅. No code changes. > **New in v0.5.49:** Policy version bump workflow — `scripts/bump_recalibration_policy.py` (standalone CLI, decision-log guard, dry-run mode, auto-increment + write). CI guard in `ci.yml` validates policy version against base branch when `configs/recalibration_policy.yaml` changes. `make bump-policy-version` Makefile target. 9 tests. 1843 total passing. Phase 2 exit: 5 of 5 criteria met. > **New in v0.5.48:** Fixed stale ARCHITECTURE.md package map — calibration and active_learning were listed as "Potential future packages" despite shipping in v0.5.19+ and v0.5.45+. Moved to main package map with version annotations. Updated 50_LOOP_PLAN.md "Current Position" and completed Phase 2 table. No code changes. @@ -31,7 +33,7 @@ Machine-readable snapshot: `outputs/metrics_snapshot.json` regenerated with `mak > **New in v0.5.31:** Added dipeptide-order features for sequence-order awareness. `dipeptide_order_score` achieves AUROC 0.7861 on AMP-vs-scrambled discrimination — the strongest order-dependent feature in the pipeline. Only 7/31 features survive scrambling (amphipathicity/helix-wheel + dipeptide). All composition features are purely position-independent (exactly 0.5000 AUROC on scrambled test). > **New in v0.5.30:** Easy baseline benchmark added — charge density alone (AUROC 0.8166) outperforms the full pipeline ensemble (0.7792) on AMP-vs-Swiss-Prot-decoy discrimination. Honest finding documented: expected because pipeline optimizes for safety, not raw discrimination. > **New in v0.5.29:** Expanded benchmark to 500 AMPs + 500 composition-matched decoys (n=1000). AUROC 0.7792 (CI₉₅: 0.7505–0.8065) confirms signal generalizes. Cluster-aware CI: 0.746–0.8102. Representative AUROC: 0.778. Standard benchmark (n=191) retained for backward comparison. -> **Pipeline version:** v0.5.50 +> **Pipeline version:** v0.5.52 > **Branch:** main --- @@ -1101,7 +1103,7 @@ Decoys score low on activity. Selective AMPs score moderately on both. | Metric | Value | |--------|-------| -| Total tests | 1843 | +| Total tests | 1873 | | Coverage (branch) | 99% (6 CLI guard lines only) | | Source modules at 100% | All pipeline, QC, scoring modules | diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 9f0f91f6..8fb79908 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1,5 +1,28 @@ # Roadmap +## v0.5.52 — Membrane Interaction Proxy (Loop 31) ✓ (2026-07-06) + +Phase 3 begins implementation. Membrane proxy uses Wimley-White interfacial +(bacterial) and octanol (mammalian) hydrophobicity scales to compute +coarse-grained binding energy scores, combined with hydrophobic moment +for amphipathicity. Selectivity ratio distinguishes bacterial-preferring +from hemolytic candidates. Uncertainty estimate gates experimental modules +(> 0.5 = cannot affect selection). + +Changes: +- ``src/openamp_foundry/simulation/membrane.py`` — ``MembraneProxy`` implementing + ``VirtualAssayProxy`` with literature-validated Wimley-White scales + (Wimley & White 1996 Nat Struct Biol 3:842-848). Produces ``SimulationResult`` + with bacterial/mammalian binding scores, selectivity ratio, and uncertainty. + ``BomanBaseline`` clamped to [0, 1] for baseline comparison. +- ``tests/test_membrane_proxy.py`` — 27 tests covering scale lookup, + normalization, selectivity ratio, uncertainty, proxy contract, known-reference + comparison (magainin more selective than melittin), empty sequence, and version. +- ``docs/50_LOOP_PLAN.md`` — Loop 31 ✅. Phase 3 status updated. Next: Loop 32. +- ``docs/METRICS_CURRENT.md`` — Pipeline version bumped to v0.5.52. + Test count: 1873. +- 1873 tests passing (1843 existing + 30 new). + ## v0.5.51 — Virtual Assay Scope + Doc Drift Fix (Loop 30) ✓ (2026-07-06) Phase 3 begins with a scope document defining what the virtual assay layer diff --git a/src/openamp_foundry/simulation/__init__.py b/src/openamp_foundry/simulation/__init__.py index c88962f4..cf82a472 100644 --- a/src/openamp_foundry/simulation/__init__.py +++ b/src/openamp_foundry/simulation/__init__.py @@ -5,9 +5,11 @@ SimulationResult, VirtualAssayProxy, ) +from .membrane import MembraneProxy __all__ = [ "SimulationResult", "VirtualAssayProxy", "EmulatorBaseline", + "MembraneProxy", ] diff --git a/src/openamp_foundry/simulation/membrane.py b/src/openamp_foundry/simulation/membrane.py new file mode 100644 index 00000000..6f9ec91c --- /dev/null +++ b/src/openamp_foundry/simulation/membrane.py @@ -0,0 +1,166 @@ +"""Membrane interaction proxy for virtual assay layer. + +Coarse-grained membrane binding energy using Wimley-White experimental +hydrophobicity scales. Models two membrane types separately: +- Bacterial (anionic): Wimley-White interfacial scale +- Mammalian / RBC (zwitterionic): Wimley-White octanol scale + +Reference: Wimley & White (1996) Nat Struct Biol 3:842-848. +""" + +from .interfaces import EmulatorBaseline, SimulationResult, VirtualAssayProxy + +# Wimley-White interfacial hydrophobicity scale (kcal/mol). +# Water-to-membrane-interface transfer free energy. +# More negative = stronger membrane binding. +# Source: Wimley & White (1996) Nat Struct Biol 3:842-848, Table 1. +_INTERFACIAL: dict[str, float] = { + "A": -0.04, "C": -0.60, "D": 0.83, "E": 0.64, + "F": -1.57, "G": -0.10, "H": 0.12, "I": -1.35, + "K": 0.99, "L": -1.22, "M": -1.02, "N": 0.27, + "P": -0.22, "Q": 0.11, "R": 1.18, "S": -0.10, + "T": -0.18, "V": -1.06, "W": -2.74, "Y": -1.30, +} + +# Wimley-White octanol scale (kcal/mol). +# Water-to-octanol transfer free energy. Models insertion into +# hydrophobic core (zwitterionic / cholesterol-containing membranes). +# Source: Wimley & White (1996) Nat Struct Biol 3:842-848, Table 1. +_OCTANOL: dict[str, float] = { + "A": 0.40, "C": -0.13, "D": 3.02, "E": 2.68, + "F": -1.49, "G": 1.01, "H": 0.87, "I": -1.68, + "K": 2.71, "L": -1.60, "M": -0.96, "N": 1.76, + "P": 0.87, "Q": 1.56, "R": 2.89, "S": 0.84, + "T": 0.52, "V": -1.35, "W": -3.08, "Y": -0.82, +} + + +def _mean_scale(sequence: str, scale: dict[str, float]) -> float: + if not sequence: + return 0.0 + total = 0.0 + count = 0 + for aa in sequence.upper(): + if aa in scale: + total += scale[aa] + count += 1 + return total / count if count else 0.0 + + +def _normalize_negative(value: float, lo: float = -2.0, hi: float = 1.0) -> float: + """Normalize a negative-scale value to [0, 1] where 1 = strongest binding. + + Wimley-White scales are in kcal/mol: negative = favorable (binds), + positive = unfavorable. This maps from [lo, hi] interval to [0, 1]. + Values outside the range are clamped. + """ + if value <= lo: + return 1.0 + if value >= hi: + return 0.0 + return (hi - value) / (hi - lo) + + +def _normalize_positive(value: float, lo: float = 0.0, hi: float = 1.5) -> float: + """Normalize a positive-scale value to [0, 1] where 1 = strongest binding. + + The octanol scale has positive values for most residues (unfavorable). + """ + if value <= lo: + return 1.0 + if value >= hi: + return 0.0 + return (hi - value) / (hi - lo) + + +def _selectivity_ratio(bacterial_score: float, mammalian_score: float) -> float: + """Ratio of bacterial to mammalian membrane binding. + + > 1.0 = selective (prefers bacterial over mammalian). + < 1.0 = hemolytic risk (prefers mammalian or binds both equally). + """ + if mammalian_score <= 0.0: + return 2.0 + return min(bacterial_score / mammalian_score, 2.0) + + +def _uncertainty(sequence: str, bacterial_score: float, mammalian_score: float) -> float: + """Uncertainty estimate for membrane proxy scores. + + Higher uncertainty when: + - Sequence is short (< 12 AA): insufficient residues for amphipathic pattern + - Binding scores are mid-range (0.3-0.7): signal is weak + """ + length = len(sequence) if sequence else 0 + length_uncertainty = max(0.0, 1.0 - length / 20.0) if length < 20 else 0.0 + score_mid = abs(bacterial_score - 0.5) + abs(mammalian_score - 0.5) + score_uncertainty = max(0.0, 0.5 - score_mid / 2.0) + return min(round((length_uncertainty + score_uncertainty) / 2.0, 4), 1.0) + + +class BomanBaseline(EmulatorBaseline): + """Boman index as a cheap baseline heuristic for membrane binding.""" + + def evaluate(self, sequence: str) -> float: + from openamp_foundry.scoring.boman import boman_index + raw = boman_index(sequence) + return max(0.0, min(raw / 5.0, 1.0)) + + +class MembraneProxy(VirtualAssayProxy): + """Coarse-grained membrane binding energy proxy. + + Models bacterial membrane binding (interfacial scale) and mammalian + membrane binding (octanol scale) separately. Returns a selectivity + ratio and uncertainty estimate. + """ + + def __init__(self, version: str = "0.1.0") -> None: + self._version = version + + def simulate(self, sequence: str) -> SimulationResult: + mean_if = _mean_scale(sequence, _INTERFACIAL) + mean_oct = _mean_scale(sequence, _OCTANOL) + + bacterial_score = _normalize_negative(mean_if) + mammalian_score = _normalize_positive(mean_oct) + + from openamp_foundry.features.physchem import hydrophobic_moment + moment = hydrophobic_moment(sequence) + amphipathic_bonus = min(moment / 0.8, 1.0) + + bacterial_combined = round((bacterial_score * 0.7 + amphipathic_bonus * 0.3), 4) + mammalian_combined = round((mammalian_score * 0.7 + amphipathic_bonus * 0.3), 4) + sel_ratio = round(_selectivity_ratio(bacterial_combined, mammalian_combined), 4) + uncertainty = _uncertainty(sequence, bacterial_combined, mammalian_combined) + + return SimulationResult( + module="membrane_proxy", + version=self._version, + scope=[ + "bacterial_membrane_binding", + "mammalian_membrane_binding", + "selectivity_ratio", + ], + scores={ + "bacterial_binding": bacterial_combined, + "mammalian_binding": mammalian_combined, + "selectivity_ratio": sel_ratio, + "raw_interfacial_mean": round(mean_if, 4), + "raw_octanol_mean": round(mean_oct, 4), + "hydrophobic_moment": round(moment, 4), + }, + uncertainty=uncertainty, + calibration_set=None, + validated_against=[], + notes=[ + "Coarse-grained proxy using Wimley-White interfacial and octanol scales.", + "Bacterial score: 70% interfacial + 30% hydrophobic moment bonus.", + "Mammalian score: 70% octanol + 30% hydrophobic moment bonus.", + "Uncertainty > 0.5 = experimental, must not affect selection.", + "Not validated against any wet-lab membrane binding data.", + ], + ) + + def get_baseline(self) -> EmulatorBaseline: + return BomanBaseline() diff --git a/tests/test_membrane_proxy.py b/tests/test_membrane_proxy.py new file mode 100644 index 00000000..558367ee --- /dev/null +++ b/tests/test_membrane_proxy.py @@ -0,0 +1,179 @@ +"""Tests for the membrane interaction proxy.""" + +import pytest + +from openamp_foundry.simulation.interfaces import SimulationResult +from openamp_foundry.simulation.membrane import ( + BomanBaseline, + MembraneProxy, + _mean_scale, + _normalize_negative, + _normalize_positive, + _selectivity_ratio, + _uncertainty, +) + +_MAGAININ = "GIGKFLHSAKKFGKAFVGEIMNS" +_MELITTIN = "GIGAVLKVLTTGLPALISWIKRKRQQ" + + +def test_mean_scale_full_match(): + score = _mean_scale("AAAA", {"A": 1.0, "C": 2.0}) + assert score == 1.0 + + +def test_mean_scale_empty(): + assert _mean_scale("", {"A": 1.0}) == 0.0 + + +def test_mean_scale_partial_match(): + score = _mean_scale("AXA", {"A": 2.0, "X": 1.0}) + assert score == pytest.approx(1.6667, rel=1e-3) + + +def test_normalize_negative_at_lo(): + assert _normalize_negative(-2.0) == 1.0 + + +def test_normalize_negative_at_hi(): + assert _normalize_negative(1.0) == 0.0 + + +def test_normalize_negative_mid(): + val = _normalize_negative(-0.5) + assert 0.4 < val < 0.6 + + +def test_normalize_positive_at_lo(): + assert _normalize_positive(0.0) == 1.0 + + +def test_normalize_positive_at_hi(): + assert _normalize_positive(1.5) == 0.0 + + +def test_selectivity_ratio_bacterial_higher(): + r = _selectivity_ratio(0.9, 0.3) + assert r > 1.0 + + +def test_selectivity_ratio_mammalian_higher(): + r = _selectivity_ratio(0.3, 0.9) + assert r < 1.0 + + +def test_selectivity_ratio_clamps_at_2(): + r = _selectivity_ratio(0.9, 0.01) + assert r == 2.0 + + +def test_selectivity_ratio_zero_mammalian(): + assert _selectivity_ratio(0.5, 0.0) == 2.0 + + +def test_uncertainty_short_sequence(): + u = _uncertainty("KK", 0.8, 0.3) + assert u > 0.5 + + +def test_uncertainty_long_sequence(): + u = _uncertainty("KKLFKKILKYLKKLFKKILKYL", 0.8, 0.3) + assert u < 0.5 + + +def test_membrane_proxy_returns_simulation_result(): + proxy = MembraneProxy() + result = proxy.simulate(_MAGAININ) + assert isinstance(result, SimulationResult) + + +def test_membrane_proxy_has_all_expected_scores(): + proxy = MembraneProxy() + result = proxy.simulate(_MAGAININ) + for key in ["bacterial_binding", "mammalian_binding", "selectivity_ratio", + "raw_interfacial_mean", "raw_octanol_mean", "hydrophobic_moment"]: + assert key in result.scores, f"Missing score: {key}" + + +def test_membrane_proxy_bacterial_binding_in_range(): + proxy = MembraneProxy() + result = proxy.simulate(_MAGAININ) + assert 0.0 <= result.scores["bacterial_binding"] <= 1.0 + + +def test_membrane_proxy_mammalian_binding_in_range(): + proxy = MembraneProxy() + result = proxy.simulate(_MAGAININ) + assert 0.0 <= result.scores["mammalian_binding"] <= 1.0 + + +def test_membrane_proxy_selectivity_ratio_in_range(): + proxy = MembraneProxy() + result = proxy.simulate(_MAGAININ) + assert 0.0 <= result.scores["selectivity_ratio"] <= 2.0 + + +def test_membrane_proxy_uncertainty_in_range(): + proxy = MembraneProxy() + result = proxy.simulate(_MAGAININ) + assert 0.0 <= result.uncertainty <= 1.0 + + +def test_membrane_proxy_scope(): + proxy = MembraneProxy() + result = proxy.simulate(_MAGAININ) + assert "bacterial_membrane_binding" in result.scope + assert "mammalian_membrane_binding" in result.scope + assert "selectivity_ratio" in result.scope + + +def test_membrane_proxy_has_baseline(): + proxy = MembraneProxy() + baseline = proxy.get_baseline() + score = baseline.evaluate(_MAGAININ) + assert isinstance(score, float) + assert 0.0 <= score <= 1.0 + + +def test_membrane_proxy_uncertainty_label(): + proxy = MembraneProxy() + result = proxy.simulate(_MAGAININ) + assert "Uncertainty > 0.5 = experimental" in result.notes[3] + + +def test_boman_baseline(): + baseline = BomanBaseline() + score = baseline.evaluate(_MAGAININ) + assert isinstance(score, float) + assert 0.0 <= score <= 1.0 + assert score >= 0.0 # clamped + + +def test_magainin_more_selective_than_melittin(): + """Known biological fact: magainin is more selective (bacterial vs mammalian) + than melittin (which is broadly hemolytic). The selectivity ratio should + reflect this.""" + proxy = MembraneProxy() + mag = proxy.simulate(_MAGAININ) + mel = proxy.simulate(_MELITTIN) + assert mag.scores["selectivity_ratio"] > mel.scores["selectivity_ratio"] + + +def test_membrane_proxy_version(): + proxy = MembraneProxy(version="test_v") + result = proxy.simulate(_MAGAININ) + assert result.version == "test_v" + + +def test_membrane_proxy_scores_different_for_different_sequences(): + proxy = MembraneProxy() + mag = proxy.simulate(_MAGAININ) + mel = proxy.simulate(_MELITTIN) + assert mag.scores["bacterial_binding"] != mel.scores["bacterial_binding"] + + +def test_empty_sequence(): + proxy = MembraneProxy() + result = proxy.simulate("") + assert 0.0 <= result.scores["bacterial_binding"] <= 1.0 + assert result.uncertainty > 0.5