diff --git a/configs/phase3.yaml b/configs/phase3.yaml index 4b335c33..4089ad07 100644 --- a/configs/phase3.yaml +++ b/configs/phase3.yaml @@ -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." diff --git a/configs/pipeline.yaml b/configs/pipeline.yaml index 82fd99a7..25e80bbb 100644 --- a/configs/pipeline.yaml +++ b/configs/pipeline.yaml @@ -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." diff --git a/src/openamp_foundry/data/loaders.py b/src/openamp_foundry/data/loaders.py index 2bb6f836..b8ec4273 100644 --- a/src/openamp_foundry/data/loaders.py +++ b/src/openamp_foundry/data/loaders.py @@ -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"]) diff --git a/src/openamp_foundry/pipeline.py b/src/openamp_foundry/pipeline.py index e231d22f..f85b3739 100644 --- a/src/openamp_foundry/pipeline.py +++ b/src/openamp_foundry/pipeline.py @@ -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))) @@ -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) diff --git a/tests/test_data_loaders.py b/tests/test_data_loaders.py index f9dbc141..dd808282 100644 --- a/tests/test_data_loaders.py +++ b/tests/test_data_loaders.py @@ -5,6 +5,8 @@ import tempfile from pathlib import Path +import pytest + from openamp_foundry.data.loaders import ( CANONICAL_AA, is_valid_sequence, @@ -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) diff --git a/tests/test_pipeline_filters.py b/tests/test_pipeline_filters.py index db037814..7ed53284 100644 --- a/tests/test_pipeline_filters.py +++ b/tests/test_pipeline_filters.py @@ -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).