11from __future__ import annotations
22
3+ import hashlib
4+ import uuid
5+ from datetime import datetime , timezone
36from pathlib import Path
47from typing import Any
58
9+ from openamp_foundry import __version__
610from openamp_foundry .config import load_config
711from openamp_foundry .data .loaders import is_valid_sequence , load_candidates_csv
812from openamp_foundry .evidence .certificate import build_certificate
1519from openamp_foundry .selection .diversity import greedy_diverse_select
1620from openamp_foundry .selection .pareto import rank_candidates
1721from openamp_foundry .types import ScoredCandidate
22+ from openamp_foundry .utils .hashing import file_sha256 , stable_json_hash
1823from 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+
2130def 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+
60107def 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" )
0 commit comments