Skip to content

Commit 85b68ce

Browse files
committed
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
1 parent 6c3ff05 commit 85b68ce

7 files changed

Lines changed: 524 additions & 16 deletions

File tree

‎Makefile‎

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,29 @@
1-
.PHONY: demo test lint clean
1+
.PHONY: demo test lint clean bench-leakage
2+
3+
PYTHON := $(shell [ -f .venv/bin/python ] && echo .venv/bin/python || echo python3)
4+
PYTEST := $(shell [ -f .venv/bin/pytest ] && echo .venv/bin/pytest || echo pytest)
5+
RUFF := $(shell [ -f .venv/bin/ruff ] && echo .venv/bin/ruff || echo ruff)
26

37
demo:
4-
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
8+
PYTHONPATH=src $(PYTHON) -m openamp_foundry.cli rank \
9+
--candidates examples/sequences/demo_candidates.csv \
10+
--references examples/known_reference/demo_known_amps.csv \
11+
--out outputs/demo_ranked.jsonl \
12+
--report outputs/demo_report.md \
13+
--cert-dir outputs/evidence \
14+
--manifest outputs/run_manifest.json
515

616
test:
7-
pytest -q
17+
$(PYTEST) -q
818

919
lint:
10-
ruff check src tests scripts
20+
$(RUFF) check src tests scripts
21+
22+
bench-leakage:
23+
PYTHONPATH=src $(PYTHON) -m openamp_foundry.cli bench leakage \
24+
--candidates examples/sequences/demo_candidates.csv \
25+
--references examples/known_reference/demo_known_amps.csv \
26+
--out outputs/leakage_report.json
1127

1228
clean:
13-
rm -rf outputs/*.jsonl outputs/*.md outputs/evidence .pytest_cache .ruff_cache
29+
rm -rf outputs/*.jsonl outputs/*.md outputs/*.json outputs/evidence .pytest_cache .ruff_cache

‎src/openamp_foundry/cli.py‎

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,22 @@ def build_parser() -> argparse.ArgumentParser:
1919
rank.add_argument("--out", required=True)
2020
rank.add_argument("--report", required=False)
2121
rank.add_argument("--cert-dir", required=False)
22+
rank.add_argument("--manifest", required=False)
2223
rank.add_argument("--config", default="configs/pipeline.yaml")
2324

2425
validate = sub.add_parser("validate", help="Validate a candidate certificate against JSON schema.")
2526
validate.add_argument("--certificate", required=True)
2627
validate.add_argument("--schema", required=True)
2728

29+
bench = sub.add_parser("bench", help="Run benchmark and leakage checks.")
30+
bench_sub = bench.add_subparsers(dest="bench_command", required=True)
31+
32+
leakage = bench_sub.add_parser("leakage", help="Find near-duplicate candidates in references.")
33+
leakage.add_argument("--candidates", required=True)
34+
leakage.add_argument("--references", required=True)
35+
leakage.add_argument("--threshold", type=float, default=0.90)
36+
leakage.add_argument("--out", required=False, help="Optional JSON output path.")
37+
2838
return parser
2939

3040

@@ -40,6 +50,7 @@ def main(argv: list[str] | None = None) -> int:
4050
report_path=args.report,
4151
cert_dir=args.cert_dir,
4252
config_path=args.config,
53+
manifest_path=args.manifest,
4354
)
4455
print(json.dumps({"status": "ok", "out": args.out, "report": args.report}, indent=2))
4556
return 0
@@ -50,9 +61,39 @@ def main(argv: list[str] | None = None) -> int:
5061
print(json.dumps({"status": "valid", "certificate": args.certificate}, indent=2))
5162
return 0
5263

64+
if args.command == "bench":
65+
return _run_bench(args)
66+
5367
parser.error("unknown command")
5468
return 2
5569

5670

71+
def _run_bench(args: argparse.Namespace) -> int:
72+
from openamp_foundry.benchmark.leakage import find_near_duplicates
73+
from openamp_foundry.data.loaders import load_candidates_csv
74+
from openamp_foundry.utils.io import write_json
75+
76+
if args.bench_command == "leakage":
77+
candidates = load_candidates_csv(args.candidates)
78+
references = load_candidates_csv(args.references)
79+
hits = find_near_duplicates(candidates, references, threshold=args.threshold)
80+
result = {
81+
"status": "ok",
82+
"threshold": args.threshold,
83+
"near_duplicate_count": len(hits),
84+
"near_duplicates": hits,
85+
"warning": (
86+
"Near-duplicates detected. If these candidates were used for training or "
87+
"scoring baseline models, benchmark results may be inflated."
88+
) if hits else None,
89+
}
90+
if args.out:
91+
write_json(args.out, result)
92+
print(json.dumps(result, indent=2))
93+
return 0
94+
95+
return 2
96+
97+
5798
if __name__ == "__main__":
5899
raise SystemExit(main())

‎src/openamp_foundry/pipeline.py‎

Lines changed: 102 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
11
from __future__ import annotations
22

3+
import hashlib
4+
import uuid
5+
from datetime import datetime, timezone
36
from pathlib import Path
47
from typing import Any
58

9+
from openamp_foundry import __version__
610
from openamp_foundry.config import load_config
711
from openamp_foundry.data.loaders import is_valid_sequence, load_candidates_csv
812
from openamp_foundry.evidence.certificate import build_certificate
@@ -15,23 +19,34 @@
1519
from openamp_foundry.selection.diversity import greedy_diverse_select
1620
from openamp_foundry.selection.pareto import rank_candidates
1721
from openamp_foundry.types import ScoredCandidate
22+
from openamp_foundry.utils.hashing import file_sha256, stable_json_hash
1823
from openamp_foundry.utils.io import write_json, write_jsonl
1924

2025

26+
def _passes_length_filter(sequence: str, min_length: int, max_length: int) -> bool:
27+
return min_length <= len(sequence) <= max_length
28+
29+
2130
def score_candidates(
2231
candidate_path: str | Path,
2332
reference_path: str | Path | None = None,
2433
config_path: str | Path = "configs/pipeline.yaml",
2534
) -> tuple[list[ScoredCandidate], dict[str, Any]]:
2635
config = load_config(config_path)
27-
allowed = set(config["filters"]["allowed_amino_acids"])
36+
filters = config.get("filters", {})
37+
allowed = set(filters.get("allowed_amino_acids", "ACDEFGHIKLMNPQRSTVWY"))
38+
min_length = int(filters.get("min_length", 8))
39+
max_length = int(filters.get("max_length", 35))
40+
2841
candidates = load_candidates_csv(candidate_path)
2942
references = load_candidates_csv(reference_path) if reference_path else []
3043
weights = config["weights"]
3144

3245
scored: list[ScoredCandidate] = []
3346
for candidate in candidates:
34-
valid = is_valid_sequence(candidate.sequence, allowed=allowed)
47+
valid_aa = is_valid_sequence(candidate.sequence, allowed=allowed)
48+
valid_len = _passes_length_filter(candidate.sequence, min_length, max_length)
49+
valid = valid_aa and valid_len
3550
features = compute_features(candidate.sequence)
3651
act = activity_likeness_score(features) if valid else 0.0
3752
safe = safety_score(features) if valid else 0.0
@@ -51,32 +66,82 @@ def score_candidates(
5166
references_checked=[str(reference_path)] if reference_path else [],
5267
nearest_reference=nearest,
5368
)
69+
item.valid = valid
5470
item.selection_reason = selection_reasons(raw_scores)
5571
item.known_failure_modes = known_failure_modes(raw_scores)
72+
if not valid_aa:
73+
item.known_failure_modes.append("Sequence contains non-canonical amino acids.")
74+
if not valid_len:
75+
item.known_failure_modes.append(
76+
f"Sequence length {len(candidate.sequence)} outside filter range "
77+
f"[{min_length}, {max_length}]."
78+
)
5679
scored.append(item)
5780
return scored, config
5881

5982

83+
def build_run_manifest(
84+
run_id: str,
85+
config: dict[str, Any],
86+
input_paths: list[Path],
87+
output_paths: list[str],
88+
generated_at: str,
89+
) -> dict[str, Any]:
90+
inputs = []
91+
for p in input_paths:
92+
if p.exists():
93+
inputs.append({"path": str(p), "sha256": file_sha256(p)})
94+
else:
95+
inputs.append({"path": str(p)})
96+
return {
97+
"run_id": run_id,
98+
"pipeline_version": __version__,
99+
"config_hash": stable_json_hash(config),
100+
"generated_at": generated_at,
101+
"inputs": [x["path"] for x in inputs],
102+
"input_hashes": {x["path"]: x.get("sha256", "N/A") for x in inputs},
103+
"outputs": output_paths,
104+
}
105+
106+
60107
def run_ranking_pipeline(
61108
candidate_path: str | Path,
62109
reference_path: str | Path | None,
63110
out_path: str | Path,
64111
report_path: str | Path | None = None,
65112
cert_dir: str | Path | None = None,
66113
config_path: str | Path = "configs/pipeline.yaml",
114+
manifest_path: str | Path | None = None,
67115
) -> list[ScoredCandidate]:
116+
run_id = str(uuid.uuid4())
117+
generated_at = datetime.now(timezone.utc).isoformat()
118+
68119
scored, config = score_candidates(candidate_path, reference_path, config_path)
120+
selection_cfg = config.get("selection", {})
121+
min_novelty = float(selection_cfg.get("min_novelty", 0.0))
122+
max_safety_risk = float(selection_cfg.get("max_safety_risk", 1.0))
123+
69124
ranked = rank_candidates(scored)
70-
top_n = int(config.get("selection", {}).get("top_n", len(ranked)))
71-
selected = greedy_diverse_select(ranked, top_n=top_n)
125+
top_n = int(selection_cfg.get("top_n", len(ranked)))
126+
127+
eligible = [
128+
item for item in ranked
129+
if getattr(item, "valid", True)
130+
and item.scores["novelty"] >= min_novelty
131+
and item.scores["safety"] >= (1.0 - max_safety_risk)
132+
]
133+
selected = greedy_diverse_select(eligible, top_n=top_n)
72134

73135
rows = []
136+
selected_ids = {item.candidate.candidate_id for item in selected}
74137
for item in ranked:
75138
rows.append(
76139
{
77140
"candidate_id": item.candidate.candidate_id,
78141
"sequence": item.candidate.sequence,
79142
"source": item.candidate.source,
143+
"valid": getattr(item, "valid", True),
144+
"selected": item.candidate.candidate_id in selected_ids,
80145
"scores": item.scores,
81146
"features": item.features,
82147
"nearest_reference": item.nearest_reference,
@@ -95,6 +160,28 @@ def run_ranking_pipeline(
95160
if report_path:
96161
write_report(report_path, ranked, selected)
97162

163+
output_paths = [str(out_path)]
164+
if report_path:
165+
output_paths.append(str(report_path))
166+
if cert_dir:
167+
output_paths.append(str(cert_dir))
168+
169+
manifest = build_run_manifest(
170+
run_id=run_id,
171+
config=config,
172+
input_paths=[Path(candidate_path)]
173+
+ ([Path(reference_path)] if reference_path else [])
174+
+ [Path(config_path)],
175+
output_paths=output_paths,
176+
generated_at=generated_at,
177+
)
178+
179+
if manifest_path:
180+
write_json(manifest_path, manifest)
181+
else:
182+
manifest_out = Path(out_path).with_name("run_manifest.json")
183+
write_json(manifest_out, manifest)
184+
98185
return ranked
99186

100187

@@ -103,23 +190,27 @@ def write_report(
103190
) -> None:
104191
p = Path(path)
105192
p.parent.mkdir(parents=True, exist_ok=True)
193+
selected_ids = {item.candidate.candidate_id for item in selected}
106194
lines = [
107-
"# OpenAMP Foundry Demo Report",
195+
"# OpenAMP Foundry Candidate Report",
108196
"",
109-
"This report is generated by transparent baseline heuristics.",
110-
"It does not prove antimicrobial activity.",
197+
"> **Disclaimer:** All scores are transparent baseline heuristics computed from "
198+
"physicochemical properties. They are NOT validated biological predictors. "
199+
"No antimicrobial activity has been demonstrated in vitro or in vivo. "
200+
"These candidates are nominated for possible future expert review and assay only.",
111201
"",
112202
f"Total candidates scored: {len(ranked)}",
113-
f"Candidates selected for evidence certificates: {len(selected)}",
203+
f"Candidates selected (passed all filters, diverse): {len(selected)}",
114204
"",
115-
"| Rank | ID | Sequence | Ensemble | Activity | Safety | Synthesis | Novelty |",
116-
"|---:|---|---|---:|---:|---:|---:|---:|",
205+
"| Rank | ID | Seq | Ensemble | Activity | Safety | Synthesis | Novelty | Selected |",
206+
"|---:|---|---|---:|---:|---:|---:|---:|:---:|",
117207
]
118208
for idx, item in enumerate(ranked, start=1):
119209
s = item.scores
210+
sel_mark = "Y" if item.candidate.candidate_id in selected_ids else ""
120211
lines.append(
121212
f"| {idx} | {item.candidate.candidate_id} | `{item.candidate.sequence}` | "
122213
f"{s['ensemble']:.4f} | {s['activity']:.4f} | {s['safety']:.4f} | "
123-
f"{s['synthesis']:.4f} | {s['novelty']:.4f} |"
214+
f"{s['synthesis']:.4f} | {s['novelty']:.4f} | {sel_mark} |"
124215
)
125216
p.write_text("\n".join(lines) + "\n", encoding="utf-8")

‎src/openamp_foundry/types.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,3 +20,4 @@ class ScoredCandidate:
2020
nearest_reference: dict[str, Any] | None = None
2121
selection_reason: list[str] = field(default_factory=list)
2222
known_failure_modes: list[str] = field(default_factory=list)
23+
valid: bool = True

0 commit comments

Comments
 (0)