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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions configs/phase3.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ selection:
top_n: 100
min_novelty: 0.05
max_safety_risk: 0.40
max_disagreement: 0.30

notes:
- "Phase 3 exploration config. Weights differ from pipeline.yaml to prioritize safety."
Expand Down
1 change: 1 addition & 0 deletions configs/pipeline.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ selection:
top_n: 100
min_novelty: 0.20
max_safety_risk: 0.70
max_disagreement: 0.40

notes:
- "These weights are transparent baseline heuristics, not validated biological predictors."
Expand Down
7 changes: 7 additions & 0 deletions src/openamp_foundry/data/loaders.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,13 @@ def load_candidates_csv(path: str | Path) -> list[PeptideCandidate]:
rows: list[PeptideCandidate] = []
with Path(path).open("r", encoding="utf-8") as f:
reader = csv.DictReader(f)
fieldnames = reader.fieldnames or []
if "sequence" not in fieldnames:
raise ValueError(
f"CSV header is missing required 'sequence' column. "
f"Found columns: {list(fieldnames)!r}. "
"Check that the CSV header spells it exactly as 'sequence'."
)
for i, row in enumerate(reader, start=1):
cid = row.get("id") or f"candidate-{i:06d}"
seq = normalize_sequence(row["sequence"])
Expand Down
2 changes: 2 additions & 0 deletions src/openamp_foundry/pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ def run_ranking_pipeline(
selection_cfg = config.get("selection", {})
min_novelty = float(selection_cfg.get("min_novelty", 0.0))
max_safety_risk = float(selection_cfg.get("max_safety_risk", 1.0))
max_disagreement = float(selection_cfg.get("max_disagreement", 1.0))

ranked = rank_candidates(scored)
top_n = int(selection_cfg.get("top_n", len(ranked)))
Expand All @@ -132,6 +133,7 @@ def run_ranking_pipeline(
if getattr(item, "valid", True)
and item.scores["novelty"] >= min_novelty
and item.scores["safety"] >= (1.0 - max_safety_risk)
and item.scores["disagreement"] <= max_disagreement
]
selected = greedy_diverse_select(eligible, top_n=top_n)

Expand Down
34 changes: 34 additions & 0 deletions tests/test_data_loaders.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
import tempfile
from pathlib import Path

import pytest

from openamp_foundry.data.loaders import (
CANONICAL_AA,
is_valid_sequence,
Expand Down Expand Up @@ -141,3 +143,35 @@ def test_uses_demo_file_successfully(self):
assert len(candidates) > 0
for c in candidates:
assert len(c.sequence) > 0

def test_missing_sequence_column_raises_value_error(self):
# A CSV with no 'sequence' column should give a helpful error, not a bare KeyError.
# This guards against users passing in a wrongly-formatted file and getting
# a cryptic crash deep in the pipeline.
with tempfile.TemporaryDirectory() as d:
path = Path(d) / "bad.csv"
with path.open("w", newline="", encoding="utf-8") as f:
writer = csv.DictWriter(f, fieldnames=["id", "seq", "source"])
writer.writeheader()
writer.writerow({"id": "C1", "seq": "KWKLF", "source": "test"})
with pytest.raises(ValueError, match="sequence"):
load_candidates_csv(path)

def test_missing_sequence_column_fires_on_header_only_csv(self):
# Header validation happens before iterating rows — zero-row files are caught too.
with tempfile.TemporaryDirectory() as d:
path = Path(d) / "header_only.csv"
path.write_text("id,seq,source\n") # header only, no data rows
with pytest.raises(ValueError, match="sequence"):
load_candidates_csv(path)

def test_missing_sequence_column_error_names_found_columns(self):
# The error message should list what columns were found to help the user.
with tempfile.TemporaryDirectory() as d:
path = Path(d) / "bad.csv"
with path.open("w", newline="", encoding="utf-8") as f:
writer = csv.DictWriter(f, fieldnames=["peptide"])
writer.writeheader()
writer.writerow({"peptide": "KWKLF"})
with pytest.raises(ValueError, match="peptide"):
load_candidates_csv(path)
62 changes: 62 additions & 0 deletions tests/test_pipeline_filters.py
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,68 @@ def test_eligibility_novelty_at_exact_minimum_passes(tmp_path):
assert rows[0]["selected"] is True


def test_disagreement_gate_blocks_high_uncertainty_candidate(tmp_path):
"""Candidates where activity and Boman index strongly disagree should be blocked.

max_disagreement caps the |activity - boman_activity| gap. The test proves the
gate is the cause of blocking by showing the SAME candidate is SELECTED when the
gate is relaxed to 1.0 (no restriction).
"""
_COMMON_CONFIG = (
"pipeline_version: '0.1.0'\n"
"filters:\n min_length: 8\n max_length: 35\n"
" allowed_amino_acids: 'ACDEFGHIKLMNPQRSTVWY'\n"
"weights:\n activity: 0.40\n safety: 0.25\n synthesis: 0.15\n novelty: 0.20\n"
"selection:\n top_n: 100\n min_novelty: 0.0\n max_safety_risk: 1.0\n"
)
strict_config = tmp_path / "strict_disagree.yaml"
strict_config.write_text(_COMMON_CONFIG + " max_disagreement: 0.0\n")
permissive_config = tmp_path / "permissive_disagree.yaml"
permissive_config.write_text(_COMMON_CONFIG + " max_disagreement: 1.0\n")

seq = "KWKLFKKIGAVLKVL"
csv_path = tmp_path / "candidates.csv"
refs_path = tmp_path / "refs.csv"
csv_path.write_text(f"id,sequence,source\nTEST-001,{seq},test\n")
refs_path.write_text("id,sequence,source\n") # empty refs → novelty=1.0

# Strict gate: disagreement > 0 for any real sequence → blocked
out_strict = tmp_path / "strict.jsonl"
run_ranking_pipeline(
candidate_path=csv_path, reference_path=refs_path,
out_path=out_strict, config_path=strict_config,
)
rows_strict = [json.loads(ln) for ln in out_strict.read_text().splitlines() if ln.strip()]
assert len(rows_strict) == 1
assert rows_strict[0]["scores"]["disagreement"] > 0.0
assert rows_strict[0]["selected"] is False # disagreement gate blocks it

# Positive control: same candidate passes when gate is off (max_disagreement=1.0)
out_permissive = tmp_path / "permissive.jsonl"
run_ranking_pipeline(
candidate_path=csv_path, reference_path=refs_path,
out_path=out_permissive, config_path=permissive_config,
)
rows_permissive = [json.loads(ln) for ln in out_permissive.read_text().splitlines() if ln.strip()]
assert len(rows_permissive) == 1
assert rows_permissive[0]["selected"] is True # relaxed gate allows it through


def test_disagreement_gate_config_values_documented(tmp_path):
"""pipeline.yaml max_disagreement=0.40, phase3.yaml max_disagreement=0.30 (stricter)."""
repo_root = Path(__file__).parents[1]
pipeline_cfg = load_config(repo_root / "configs" / "pipeline.yaml")
phase3_cfg = load_config(repo_root / "configs" / "phase3.yaml")

pipeline_max = float(pipeline_cfg["selection"]["max_disagreement"])
phase3_max = float(phase3_cfg["selection"]["max_disagreement"])

# phase3 is stricter (lower tolerance for disagreement)
assert phase3_max < pipeline_max
assert abs(pipeline_max - 0.40) < 0.01
assert abs(phase3_max - 0.30) < 0.01


def test_phase3_config_has_stricter_safety_filter_than_pipeline():
"""phase3.yaml max_safety_risk=0.40 (safety>=0.60) vs pipeline.yaml 0.70 (safety>=0.30).

Expand Down
Loading