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
26 changes: 21 additions & 5 deletions Makefile
Original file line number Diff line number Diff line change
@@ -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
41 changes: 41 additions & 0 deletions src/openamp_foundry/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand All @@ -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
Expand All @@ -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())
113 changes: 102 additions & 11 deletions src/openamp_foundry/pipeline.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand All @@ -51,32 +66,82 @@ 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,
out_path: str | Path,
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,
Expand All @@ -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


Expand All @@ -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")
1 change: 1 addition & 0 deletions src/openamp_foundry/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading
Loading