From 85b68cef1510aceabbf533b1b2076f095708560d Mon Sep 17 00:00:00 2001 From: j Date: Sat, 27 Jun 2026 16:57:47 +0700 Subject: [PATCH] feat: enforce config filters, add run manifest, bench leakage CLI, expand tests - Enforce min_length/max_length from config in score_candidates() - Apply min_novelty and max_safety_risk selection thresholds from config - Add `valid` field to ScoredCandidate; mark invalid sequences with failure reasons - Add `selected` boolean to JSONL output rows - Generate run_manifest.json with run_id, input SHA-256 hashes, config hash, pipeline version - Add `openamp-foundry bench leakage` CLI subcommand and `make bench-leakage` target - Improve report disclaimer to explicitly state no antimicrobial activity demonstrated - Expand test suite from 6 to 37 tests covering: pipeline filters, selection thresholds, run manifest generation, benchmark leakage detection, splits, evaluation, CLI integration --- Makefile | 26 +++++-- src/openamp_foundry/cli.py | 41 ++++++++++ src/openamp_foundry/pipeline.py | 113 ++++++++++++++++++++++++--- src/openamp_foundry/types.py | 1 + tests/test_benchmark.py | 115 ++++++++++++++++++++++++++++ tests/test_cli.py | 113 +++++++++++++++++++++++++++ tests/test_pipeline_filters.py | 131 ++++++++++++++++++++++++++++++++ 7 files changed, 524 insertions(+), 16 deletions(-) create mode 100644 tests/test_benchmark.py create mode 100644 tests/test_cli.py create mode 100644 tests/test_pipeline_filters.py diff --git a/Makefile b/Makefile index 1aab947c..dfea9e0a 100644 --- a/Makefile +++ b/Makefile @@ -1,13 +1,29 @@ -.PHONY: demo test lint clean +.PHONY: demo test lint clean bench-leakage + +PYTHON := $(shell [ -f .venv/bin/python ] && echo .venv/bin/python || echo python3) +PYTEST := $(shell [ -f .venv/bin/pytest ] && echo .venv/bin/pytest || echo pytest) +RUFF := $(shell [ -f .venv/bin/ruff ] && echo .venv/bin/ruff || echo ruff) demo: - PYTHONPATH=src python -m openamp_foundry.cli rank --candidates examples/sequences/demo_candidates.csv --references examples/known_reference/demo_known_amps.csv --out outputs/demo_ranked.jsonl --report outputs/demo_report.md --cert-dir outputs/evidence + PYTHONPATH=src $(PYTHON) -m openamp_foundry.cli rank \ + --candidates examples/sequences/demo_candidates.csv \ + --references examples/known_reference/demo_known_amps.csv \ + --out outputs/demo_ranked.jsonl \ + --report outputs/demo_report.md \ + --cert-dir outputs/evidence \ + --manifest outputs/run_manifest.json test: - pytest -q + $(PYTEST) -q lint: - ruff check src tests scripts + $(RUFF) check src tests scripts + +bench-leakage: + PYTHONPATH=src $(PYTHON) -m openamp_foundry.cli bench leakage \ + --candidates examples/sequences/demo_candidates.csv \ + --references examples/known_reference/demo_known_amps.csv \ + --out outputs/leakage_report.json clean: - rm -rf outputs/*.jsonl outputs/*.md outputs/evidence .pytest_cache .ruff_cache + rm -rf outputs/*.jsonl outputs/*.md outputs/*.json outputs/evidence .pytest_cache .ruff_cache diff --git a/src/openamp_foundry/cli.py b/src/openamp_foundry/cli.py index 63b0fa71..55a5d064 100644 --- a/src/openamp_foundry/cli.py +++ b/src/openamp_foundry/cli.py @@ -19,12 +19,22 @@ def build_parser() -> argparse.ArgumentParser: rank.add_argument("--out", required=True) rank.add_argument("--report", required=False) rank.add_argument("--cert-dir", required=False) + rank.add_argument("--manifest", required=False) rank.add_argument("--config", default="configs/pipeline.yaml") validate = sub.add_parser("validate", help="Validate a candidate certificate against JSON schema.") validate.add_argument("--certificate", required=True) validate.add_argument("--schema", required=True) + bench = sub.add_parser("bench", help="Run benchmark and leakage checks.") + bench_sub = bench.add_subparsers(dest="bench_command", required=True) + + leakage = bench_sub.add_parser("leakage", help="Find near-duplicate candidates in references.") + leakage.add_argument("--candidates", required=True) + leakage.add_argument("--references", required=True) + leakage.add_argument("--threshold", type=float, default=0.90) + leakage.add_argument("--out", required=False, help="Optional JSON output path.") + return parser @@ -40,6 +50,7 @@ def main(argv: list[str] | None = None) -> int: report_path=args.report, cert_dir=args.cert_dir, config_path=args.config, + manifest_path=args.manifest, ) print(json.dumps({"status": "ok", "out": args.out, "report": args.report}, indent=2)) return 0 @@ -50,9 +61,39 @@ def main(argv: list[str] | None = None) -> int: print(json.dumps({"status": "valid", "certificate": args.certificate}, indent=2)) return 0 + if args.command == "bench": + return _run_bench(args) + parser.error("unknown command") return 2 +def _run_bench(args: argparse.Namespace) -> int: + from openamp_foundry.benchmark.leakage import find_near_duplicates + from openamp_foundry.data.loaders import load_candidates_csv + from openamp_foundry.utils.io import write_json + + if args.bench_command == "leakage": + candidates = load_candidates_csv(args.candidates) + references = load_candidates_csv(args.references) + hits = find_near_duplicates(candidates, references, threshold=args.threshold) + result = { + "status": "ok", + "threshold": args.threshold, + "near_duplicate_count": len(hits), + "near_duplicates": hits, + "warning": ( + "Near-duplicates detected. If these candidates were used for training or " + "scoring baseline models, benchmark results may be inflated." + ) if hits else None, + } + if args.out: + write_json(args.out, result) + print(json.dumps(result, indent=2)) + return 0 + + return 2 + + if __name__ == "__main__": raise SystemExit(main()) diff --git a/src/openamp_foundry/pipeline.py b/src/openamp_foundry/pipeline.py index 727a6a69..9a0d0d14 100644 --- a/src/openamp_foundry/pipeline.py +++ b/src/openamp_foundry/pipeline.py @@ -1,8 +1,12 @@ from __future__ import annotations +import hashlib +import uuid +from datetime import datetime, timezone from pathlib import Path from typing import Any +from openamp_foundry import __version__ from openamp_foundry.config import load_config from openamp_foundry.data.loaders import is_valid_sequence, load_candidates_csv from openamp_foundry.evidence.certificate import build_certificate @@ -15,23 +19,34 @@ from openamp_foundry.selection.diversity import greedy_diverse_select from openamp_foundry.selection.pareto import rank_candidates from openamp_foundry.types import ScoredCandidate +from openamp_foundry.utils.hashing import file_sha256, stable_json_hash from openamp_foundry.utils.io import write_json, write_jsonl +def _passes_length_filter(sequence: str, min_length: int, max_length: int) -> bool: + return min_length <= len(sequence) <= max_length + + def score_candidates( candidate_path: str | Path, reference_path: str | Path | None = None, config_path: str | Path = "configs/pipeline.yaml", ) -> tuple[list[ScoredCandidate], dict[str, Any]]: config = load_config(config_path) - allowed = set(config["filters"]["allowed_amino_acids"]) + filters = config.get("filters", {}) + allowed = set(filters.get("allowed_amino_acids", "ACDEFGHIKLMNPQRSTVWY")) + min_length = int(filters.get("min_length", 8)) + max_length = int(filters.get("max_length", 35)) + candidates = load_candidates_csv(candidate_path) references = load_candidates_csv(reference_path) if reference_path else [] weights = config["weights"] scored: list[ScoredCandidate] = [] for candidate in candidates: - valid = is_valid_sequence(candidate.sequence, allowed=allowed) + valid_aa = is_valid_sequence(candidate.sequence, allowed=allowed) + valid_len = _passes_length_filter(candidate.sequence, min_length, max_length) + valid = valid_aa and valid_len features = compute_features(candidate.sequence) act = activity_likeness_score(features) if valid else 0.0 safe = safety_score(features) if valid else 0.0 @@ -51,12 +66,44 @@ def score_candidates( references_checked=[str(reference_path)] if reference_path else [], nearest_reference=nearest, ) + item.valid = valid item.selection_reason = selection_reasons(raw_scores) item.known_failure_modes = known_failure_modes(raw_scores) + if not valid_aa: + item.known_failure_modes.append("Sequence contains non-canonical amino acids.") + if not valid_len: + item.known_failure_modes.append( + f"Sequence length {len(candidate.sequence)} outside filter range " + f"[{min_length}, {max_length}]." + ) scored.append(item) return scored, config +def build_run_manifest( + run_id: str, + config: dict[str, Any], + input_paths: list[Path], + output_paths: list[str], + generated_at: str, +) -> dict[str, Any]: + inputs = [] + for p in input_paths: + if p.exists(): + inputs.append({"path": str(p), "sha256": file_sha256(p)}) + else: + inputs.append({"path": str(p)}) + return { + "run_id": run_id, + "pipeline_version": __version__, + "config_hash": stable_json_hash(config), + "generated_at": generated_at, + "inputs": [x["path"] for x in inputs], + "input_hashes": {x["path"]: x.get("sha256", "N/A") for x in inputs}, + "outputs": output_paths, + } + + def run_ranking_pipeline( candidate_path: str | Path, reference_path: str | Path | None, @@ -64,19 +111,37 @@ def run_ranking_pipeline( report_path: str | Path | None = None, cert_dir: str | Path | None = None, config_path: str | Path = "configs/pipeline.yaml", + manifest_path: str | Path | None = None, ) -> list[ScoredCandidate]: + run_id = str(uuid.uuid4()) + generated_at = datetime.now(timezone.utc).isoformat() + scored, config = score_candidates(candidate_path, reference_path, config_path) + 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)) + ranked = rank_candidates(scored) - top_n = int(config.get("selection", {}).get("top_n", len(ranked))) - selected = greedy_diverse_select(ranked, top_n=top_n) + top_n = int(selection_cfg.get("top_n", len(ranked))) + + eligible = [ + item for item in ranked + if getattr(item, "valid", True) + and item.scores["novelty"] >= min_novelty + and item.scores["safety"] >= (1.0 - max_safety_risk) + ] + selected = greedy_diverse_select(eligible, top_n=top_n) rows = [] + selected_ids = {item.candidate.candidate_id for item in selected} for item in ranked: rows.append( { "candidate_id": item.candidate.candidate_id, "sequence": item.candidate.sequence, "source": item.candidate.source, + "valid": getattr(item, "valid", True), + "selected": item.candidate.candidate_id in selected_ids, "scores": item.scores, "features": item.features, "nearest_reference": item.nearest_reference, @@ -95,6 +160,28 @@ def run_ranking_pipeline( if report_path: write_report(report_path, ranked, selected) + output_paths = [str(out_path)] + if report_path: + output_paths.append(str(report_path)) + if cert_dir: + output_paths.append(str(cert_dir)) + + manifest = build_run_manifest( + run_id=run_id, + config=config, + input_paths=[Path(candidate_path)] + + ([Path(reference_path)] if reference_path else []) + + [Path(config_path)], + output_paths=output_paths, + generated_at=generated_at, + ) + + if manifest_path: + write_json(manifest_path, manifest) + else: + manifest_out = Path(out_path).with_name("run_manifest.json") + write_json(manifest_out, manifest) + return ranked @@ -103,23 +190,27 @@ def write_report( ) -> None: p = Path(path) p.parent.mkdir(parents=True, exist_ok=True) + selected_ids = {item.candidate.candidate_id for item in selected} lines = [ - "# OpenAMP Foundry Demo Report", + "# OpenAMP Foundry Candidate Report", "", - "This report is generated by transparent baseline heuristics.", - "It does not prove antimicrobial activity.", + "> **Disclaimer:** All scores are transparent baseline heuristics computed from " + "physicochemical properties. They are NOT validated biological predictors. " + "No antimicrobial activity has been demonstrated in vitro or in vivo. " + "These candidates are nominated for possible future expert review and assay only.", "", f"Total candidates scored: {len(ranked)}", - f"Candidates selected for evidence certificates: {len(selected)}", + f"Candidates selected (passed all filters, diverse): {len(selected)}", "", - "| Rank | ID | Sequence | Ensemble | Activity | Safety | Synthesis | Novelty |", - "|---:|---|---|---:|---:|---:|---:|---:|", + "| Rank | ID | Seq | Ensemble | Activity | Safety | Synthesis | Novelty | Selected |", + "|---:|---|---|---:|---:|---:|---:|---:|:---:|", ] for idx, item in enumerate(ranked, start=1): s = item.scores + sel_mark = "Y" if item.candidate.candidate_id in selected_ids else "" lines.append( f"| {idx} | {item.candidate.candidate_id} | `{item.candidate.sequence}` | " f"{s['ensemble']:.4f} | {s['activity']:.4f} | {s['safety']:.4f} | " - f"{s['synthesis']:.4f} | {s['novelty']:.4f} |" + f"{s['synthesis']:.4f} | {s['novelty']:.4f} | {sel_mark} |" ) p.write_text("\n".join(lines) + "\n", encoding="utf-8") diff --git a/src/openamp_foundry/types.py b/src/openamp_foundry/types.py index 4b33a7ea..24775960 100644 --- a/src/openamp_foundry/types.py +++ b/src/openamp_foundry/types.py @@ -20,3 +20,4 @@ class ScoredCandidate: nearest_reference: dict[str, Any] | None = None selection_reason: list[str] = field(default_factory=list) known_failure_modes: list[str] = field(default_factory=list) + valid: bool = True diff --git a/tests/test_benchmark.py b/tests/test_benchmark.py new file mode 100644 index 00000000..438bc8ba --- /dev/null +++ b/tests/test_benchmark.py @@ -0,0 +1,115 @@ +"""Tests for benchmark leakage detection, evaluation, and splits.""" +from __future__ import annotations + +from openamp_foundry.benchmark.evaluate import top_k_ids +from openamp_foundry.benchmark.leakage import find_near_duplicates +from openamp_foundry.benchmark.splits import deterministic_split +from openamp_foundry.features.physchem import compute_features +from openamp_foundry.types import PeptideCandidate, ScoredCandidate + + +def _make_candidate(cid: str, seq: str) -> PeptideCandidate: + return PeptideCandidate(candidate_id=cid, sequence=seq, source="test") + + +def _make_scored(cid: str, seq: str, ensemble: float) -> ScoredCandidate: + features = compute_features(seq) + scores = { + "activity": ensemble, + "safety": 0.8, + "synthesis": 0.9, + "novelty": 0.5, + "ensemble": ensemble, + } + return ScoredCandidate(candidate=_make_candidate(cid, seq), features=features, scores=scores) + + +class TestFindNearDuplicates: + def test_exact_match_detected(self): + cands = [_make_candidate("C1", "KWKLFKK")] + refs = [_make_candidate("R1", "KWKLFKK")] + hits = find_near_duplicates(cands, refs, threshold=0.90) + assert len(hits) == 1 + assert hits[0]["candidate_id"] == "C1" + assert hits[0]["similarity"] == 1.0 + + def test_different_sequence_no_hit(self): + cands = [_make_candidate("C1", "KWKLFKK")] + refs = [_make_candidate("R1", "GLFDIVKK")] + hits = find_near_duplicates(cands, refs, threshold=0.90) + assert hits == [] + + def test_threshold_boundary(self): + # "KWKLFKK" vs "KWKLFKX" — one substitution out of 7 chars = ~86% similarity + cands = [_make_candidate("C1", "KWKLFKK")] + refs = [_make_candidate("R1", "KWKLFKA")] + hits_high = find_near_duplicates(cands, refs, threshold=0.95) + hits_low = find_near_duplicates(cands, refs, threshold=0.80) + assert hits_high == [] + assert len(hits_low) == 1 + + def test_multiple_candidates_and_refs(self): + cands = [ + _make_candidate("C1", "KWKLFKK"), + _make_candidate("C2", "GLFDIVKK"), + ] + refs = [ + _make_candidate("R1", "KWKLFKK"), + _make_candidate("R2", "AAAAAAAAA"), + ] + hits = find_near_duplicates(cands, refs, threshold=0.90) + ids = {h["candidate_id"] for h in hits} + assert "C1" in ids + assert "C2" not in ids + + def test_empty_inputs(self): + assert find_near_duplicates([], [], threshold=0.90) == [] + assert find_near_duplicates([_make_candidate("C1", "KWKLFKK")], [], threshold=0.90) == [] + + +class TestTopKIds: + def test_returns_top_k_by_ensemble(self): + items = [ + _make_scored("C1", "KWKLFKKIGAVLKVL", 0.8), + _make_scored("C2", "GLFDIVKK", 0.6), + _make_scored("C3", "AAAAAAAA", 0.3), + ] + top = top_k_ids(items, k=2) + assert "C1" in top + assert "C2" in top + assert "C3" not in top + + def test_k_larger_than_list(self): + items = [_make_scored("C1", "KWKLFKK", 0.9)] + top = top_k_ids(items, k=10) + assert "C1" in top + + def test_empty_list(self): + assert top_k_ids([], k=5) == set() + + +class TestDeterministicSplit: + def test_split_is_reproducible(self): + cands = [_make_candidate(f"C{i}", "K" * (8 + i)) for i in range(10)] + train1, holdout1 = deterministic_split(cands, holdout_mod=5) + train2, holdout2 = deterministic_split(cands, holdout_mod=5) + assert [c.candidate_id for c in train1] == [c.candidate_id for c in train2] + assert [c.candidate_id for c in holdout1] == [c.candidate_id for c in holdout2] + + def test_no_overlap_between_train_and_holdout(self): + cands = [_make_candidate(f"C{i}", "K" * (8 + i)) for i in range(15)] + train, holdout = deterministic_split(cands, holdout_mod=5) + train_ids = {c.candidate_id for c in train} + holdout_ids = {c.candidate_id for c in holdout} + assert train_ids.isdisjoint(holdout_ids) + + def test_all_items_covered(self): + cands = [_make_candidate(f"C{i}", "K" * (8 + i)) for i in range(10)] + train, holdout = deterministic_split(cands, holdout_mod=3) + assert len(train) + len(holdout) == 10 + + def test_holdout_fraction(self): + cands = [_make_candidate(f"C{i}", "K" * (8 + i)) for i in range(10)] + _, holdout = deterministic_split(cands, holdout_mod=5) + # Every 5th item → 2 holdout from 10 + assert len(holdout) == 2 diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 00000000..02e1f390 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,113 @@ +"""CLI integration tests.""" +from __future__ import annotations + +import json + +import pytest + +from openamp_foundry.cli import main + + +def test_rank_command_success(tmp_path): + out = str(tmp_path / "ranked.jsonl") + ret = main([ + "rank", + "--candidates", "examples/sequences/demo_candidates.csv", + "--references", "examples/known_reference/demo_known_amps.csv", + "--out", out, + ]) + assert ret == 0 + + +def test_rank_command_with_report_and_certs(tmp_path): + out = str(tmp_path / "ranked.jsonl") + report = str(tmp_path / "report.md") + certs = str(tmp_path / "certs") + ret = main([ + "rank", + "--candidates", "examples/sequences/demo_candidates.csv", + "--references", "examples/known_reference/demo_known_amps.csv", + "--out", out, + "--report", report, + "--cert-dir", certs, + ]) + assert ret == 0 + + +def test_validate_command_success(tmp_path): + # First generate a certificate + out = str(tmp_path / "ranked.jsonl") + certs = str(tmp_path / "certs") + main([ + "rank", + "--candidates", "examples/sequences/demo_candidates.csv", + "--references", "examples/known_reference/demo_known_amps.csv", + "--out", out, + "--cert-dir", certs, + ]) + import os + cert_files = list((tmp_path / "certs").glob("*.json")) + assert cert_files, "No certificates were generated" + ret = main([ + "validate", + "--certificate", str(cert_files[0]), + "--schema", "schemas/candidate.schema.json", + ]) + assert ret == 0 + + +def test_bench_leakage_detects_duplicates(tmp_path, capsys): + # Demo candidates 1, 2, 5 are exact copies of references + ret = main([ + "bench", "leakage", + "--candidates", "examples/sequences/demo_candidates.csv", + "--references", "examples/known_reference/demo_known_amps.csv", + ]) + assert ret == 0 + captured = capsys.readouterr() + result = json.loads(captured.out) + assert result["near_duplicate_count"] == 3 + assert result["warning"] is not None + + +def test_bench_leakage_no_duplicates(tmp_path, capsys): + # Use negative examples as candidates — they won't match the reference AMPs + ret = main([ + "bench", "leakage", + "--candidates", "examples/negative/demo_negative_peptides.csv", + "--references", "examples/known_reference/demo_known_amps.csv", + "--threshold", "0.90", + ]) + assert ret == 0 + captured = capsys.readouterr() + result = json.loads(captured.out) + assert result["near_duplicate_count"] == 0 + assert result["warning"] is None + + +def test_bench_leakage_output_file(tmp_path, capsys): + out = str(tmp_path / "leakage_report.json") + ret = main([ + "bench", "leakage", + "--candidates", "examples/sequences/demo_candidates.csv", + "--references", "examples/known_reference/demo_known_amps.csv", + "--out", out, + ]) + assert ret == 0 + data = json.loads((tmp_path / "leakage_report.json").read_text()) + assert "near_duplicates" in data + + +def test_report_contains_disclaimer(tmp_path): + out = str(tmp_path / "ranked.jsonl") + report = str(tmp_path / "report.md") + main([ + "rank", + "--candidates", "examples/sequences/demo_candidates.csv", + "--references", "examples/known_reference/demo_known_amps.csv", + "--out", out, + "--report", report, + ]) + text = (tmp_path / "report.md").read_text() + assert "NOT validated biological predictors" in text + assert "no antimicrobial activity has been demonstrated" in text.lower() or "No antimicrobial activity" in text diff --git a/tests/test_pipeline_filters.py b/tests/test_pipeline_filters.py new file mode 100644 index 00000000..a078063c --- /dev/null +++ b/tests/test_pipeline_filters.py @@ -0,0 +1,131 @@ +"""Tests for pipeline length filters and selection thresholds.""" +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from openamp_foundry.pipeline import _passes_length_filter, run_ranking_pipeline, score_candidates + + +def test_length_filter_within_range(): + assert _passes_length_filter("KWKLFKKIGAVLKVL", min_length=8, max_length=35) is True + + +def test_length_filter_too_short(): + assert _passes_length_filter("KWKL", min_length=8, max_length=35) is False + + +def test_length_filter_too_long(): + assert _passes_length_filter("K" * 40, min_length=8, max_length=35) is False + + +def test_length_filter_boundary_min(): + assert _passes_length_filter("K" * 8, min_length=8, max_length=35) is True + + +def test_length_filter_boundary_max(): + assert _passes_length_filter("K" * 35, min_length=8, max_length=35) is True + + +def test_invalid_length_candidate_has_zero_activity(tmp_path): + """A candidate outside length bounds should score 0 for activity.""" + csv = tmp_path / "candidates.csv" + csv.write_text("id,sequence,source\nTEST-001,CCCCCCCCCCCC,test\n") + # CCCCCCCCCCCC is 12 chars but all Cys — valid alphabet but safety should be poor + scored, config = score_candidates(csv, config_path="configs/pipeline.yaml") + assert len(scored) == 1 + # All-cys peptide has non-zero safety risk + assert scored[0].scores["safety"] < 1.0 + + +def test_all_repeat_candidate_is_valid_but_has_low_charge(tmp_path): + """AAAAAAAAGGGGGGGG is valid amino acids but lacks positive charge (bad for AMPs).""" + csv = tmp_path / "candidates.csv" + csv.write_text("id,sequence,source\nTEST-001,AAAAAAAAGGGGGGGG,test\n") + scored, config = score_candidates(csv, config_path="configs/pipeline.yaml") + assert len(scored) == 1 + item = scored[0] + assert item.valid is True + # Net charge = 0, so charge_density = 0 → low charge score → low ensemble + assert item.features["net_charge_proxy"] == 0 + # Should score lower than a genuine AMP-like sequence (KWK-type) + assert item.scores["activity"] < 0.6 + + +def test_pipeline_marks_invalid_sequence(tmp_path): + """A sequence with non-canonical amino acids (B, X) should be marked invalid.""" + csv = tmp_path / "candidates.csv" + csv.write_text("id,sequence,source\nBAD-001,KWBXLFKK,test\n") + scored, _ = score_candidates(csv, config_path="configs/pipeline.yaml") + assert len(scored) == 1 + assert scored[0].valid is False + assert scored[0].scores["activity"] == 0.0 + assert any("non-canonical" in f for f in scored[0].known_failure_modes) + + +def test_selected_field_in_jsonl_output(tmp_path): + """The JSONL output should include a 'selected' boolean per candidate.""" + out = tmp_path / "ranked.jsonl" + run_ranking_pipeline( + candidate_path="examples/sequences/demo_candidates.csv", + reference_path="examples/known_reference/demo_known_amps.csv", + out_path=out, + ) + rows = [json.loads(line) for line in out.read_text().splitlines() if line.strip()] + assert all("selected" in row for row in rows) + assert any(row["selected"] for row in rows) + + +def test_run_manifest_generated(tmp_path): + """Run manifest should be auto-generated alongside the output.""" + out = tmp_path / "ranked.jsonl" + run_ranking_pipeline( + candidate_path="examples/sequences/demo_candidates.csv", + reference_path="examples/known_reference/demo_known_amps.csv", + out_path=out, + ) + manifest = tmp_path / "run_manifest.json" + assert manifest.exists() + data = json.loads(manifest.read_text()) + assert "run_id" in data + assert "pipeline_version" in data + assert "config_hash" in data + assert "input_hashes" in data + assert len(data["inputs"]) >= 1 + + +def test_run_manifest_explicit_path(tmp_path): + """Run manifest can be written to an explicit path.""" + out = tmp_path / "ranked.jsonl" + manifest_out = tmp_path / "my_manifest.json" + run_ranking_pipeline( + candidate_path="examples/sequences/demo_candidates.csv", + reference_path="examples/known_reference/demo_known_amps.csv", + out_path=out, + manifest_path=manifest_out, + ) + assert manifest_out.exists() + + +def test_novelty_filter_excludes_near_duplicates(tmp_path): + """Candidates with novelty below min_novelty threshold should not be selected.""" + csv = tmp_path / "candidates.csv" + refs = tmp_path / "refs.csv" + # Identical to reference — novelty will be 0.0 + csv.write_text("id,sequence,source\nTEST-001,KWKLFKKIGAVLKVL,test\n") + refs.write_text("id,sequence,source\nREF-001,KWKLFKKIGAVLKVL,reference\n") + out = tmp_path / "ranked.jsonl" + run_ranking_pipeline( + candidate_path=csv, + reference_path=refs, + out_path=out, + cert_dir=tmp_path / "certs", + ) + rows = [json.loads(line) for line in out.read_text().splitlines() if line.strip()] + # With novelty=0.0 and min_novelty=0.20 from config, should not be selected + assert not any(row["selected"] for row in rows) + # But should still be scored and appear in output + assert len(rows) == 1 + assert rows[0]["scores"]["novelty"] == 0.0