Skip to content

Commit c3f0a46

Browse files
authored
feat: integrate expert composite into pipeline ranking — --ranking-mode expert (#118)
The expert composite scorer (scoring/expert.py) was benchmarked but never connected to the actual pipeline. The triage benchmark (v0.5.12) showed the ensemble has an anti-selective bias (sel_vs_hem AUROC=0.466, below random). This integration makes the expert composite available as an optional ranking mode that partially corrects this bias. Changes: - score_candidates() now computes expert_composite and hemolysis_risk for every candidate, stored alongside ensemble in the scores dict - rank_candidates() accepts ranking_mode='expert' to sort by expert composite (default remains 'ensemble') - run_ranking_pipeline() accepts ranking_mode, recorded in batch report JSON - CLI: openamp-foundry rank --ranking-mode expert flag added - selection/pareto.py: select_top() also accepts ranking_mode Verification: - Expert-ranked top-5 have lower mean hemolysis_risk than ensemble-ranked top-5 on mixed benchmark (test_expert_top_5_have_lower_hemolysis_risk) - Expert ranking produces different order than ensemble (not identical) - Default ranking unchanged (backward compatible) - Batch report records ranking_mode field - 1502 tests pass, 1 skipped (pre-existing) - Ruff clean on all modified files Honest limitation: The expert composite's hemolysis components are not statistically significant on the expanded n=179 benchmark (AUROC=0.565, CI 0.47-0.66). Expert ranking is a safety-aware alternative, not a validated safety guarantee. Wet-lab hemolysis assay remains mandatory.
1 parent 28f1d8b commit c3f0a46

7 files changed

Lines changed: 237 additions & 8 deletions

File tree

‎docs/ARCHITECTURE.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,9 +37,9 @@ The purpose of the added layers is not simulation theater. It is to improve whic
3737
| `openamp_foundry.data` | loading and normalizing candidate/reference data |
3838
| `openamp_foundry.features` | physicochemical feature extraction |
3939
| `openamp_foundry.scoring` | activity, safety, hemolysis risk, novelty, synthesis, ensemble, expert composite scoring |
40-
| `openamp_foundry.selection` | ranking and diversity selection |
40+
| `openamp_foundry.selection` | ranking (ensemble or expert composite) and diversity selection |
4141
| `openamp_foundry.evidence` | JSON certificate generation and validation |
42-
| `openamp_foundry.benchmark` | leakage checks, cluster-split benchmark, expert ablation benchmark, within-AMP selectivity benchmark, and evaluation scaffolding |
42+
| `openamp_foundry.benchmark` | leakage checks, cluster-split benchmark, expert ablation benchmark, within-AMP selectivity benchmark, triage benchmark, and evaluation scaffolding |
4343
| `openamp_foundry.generators` | safe, bounded toy candidate generation |
4444
| `openamp_foundry.simulation` | membrane/selectivity/stability proxy modeling (scaffolded) |
4545

‎docs/METRICS_CURRENT.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -475,4 +475,5 @@ The MODERATE class (HC50 25-100, n=68) is excluded from the binary task.
475475
| 2026-07-01 | **Hemolysis benchmark expanded:** 42 -> 238 peptides using DBAASP human erythrocyte data (54 hemolytic vs 125 selective, n=179 binary). Hemolysis risk scorer detection AUROC drops 0.9218 -> 0.5650 (CI 0.47-0.66) — original performance was small-sample inflation. Direction correct, not significant. Safety scorer detection improves 0.3844 -> 0.5116 (still not significant). 196 new peptides from DBAASP v3. | OpenAMP loop |
476476
| 2026-07-01 | Dedicated hemolysis risk scorer: 4-component score (synth+aromatic+face+cys) achieves detection AUROC=0.9218 (CI: 0.82-0.99); integrated into expert composite (detection 0.5119→0.6429); safety scorer unchanged; 1471 tests | OpenAMP loop |
477477
| 2026-07-01 | Within-AMP selectivity benchmark added: safety scorer FAILS hemolysis detection (AUROC=0.3844); synthesis is only significant risk detector (AUROC=0.8027); expert composite better than ensemble but not significant (0.5119 vs 0.3486) | OpenAMP loop |
478+
| 2026-07-01 | Expert composite ranking integration: `score_candidates()` now computes `expert_composite` and `hemolysis_risk`; `--ranking-mode expert` CLI flag; expert-ranked top-5 have lower mean hemolysis_risk than ensemble | OpenAMP loop |
478479
| 2026-06-29 | Initial — expanded benchmark (PR #110) | OpenAMP CI |

‎docs/ROADMAP.md‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,32 @@ Implemented during the pre-wet-lab improvement loop (PRs #31–#54):
209209
- Makefile: `make bench-triage`
210210
- 16 new tests (15 triage benchmark + 1 CLI integration); 1487 tests total.
211211

212+
## v0.5.13 — Expert composite ranking integration ✓ (2026-07-01)
213+
214+
The expert composite scorer (`scoring/expert.py`) was benchmarked but never connected
215+
to the pipeline. This integration makes it available as an optional ranking mode.
216+
217+
Changes:
218+
- `score_candidates()` now computes `expert_composite` and `hemolysis_risk` for every
219+
candidate (stored in `scores` dict alongside `ensemble`)
220+
- `rank_candidates()` accepts `ranking_mode="expert"` to rank by expert composite
221+
instead of ensemble (default remains "ensemble")
222+
- `run_ranking_pipeline()` accepts `ranking_mode` parameter, recorded in batch report
223+
- CLI: `openamp-foundry rank --ranking-mode expert` flag added
224+
- `selection/pareto.py`: `select_top()` also accepts `ranking_mode`
225+
226+
Why this matters:
227+
The triage benchmark (v0.5.12) showed the ensemble has an anti-selective bias
228+
(sel_vs_hem AUROC=0.466). The expert composite incorporates selectivity_proxy,
229+
hemolysis risk, helix-hinge analysis, and motif novelty — partially correcting
230+
this bias. Expert-ranked top-5 candidates have lower mean hemolysis_risk than
231+
ensemble-ranked top-5 on the mixed benchmark set (verified by test).
232+
233+
Honest limitation: The expert composite's hemolysis components are not statistically
234+
significant detectors on the expanded n=179 benchmark (AUROC=0.565, CI 0.47-0.66).
235+
Expert ranking is a safety-aware alternative, not a validated safety guarantee.
236+
Wet-lab hemolysis assay remains mandatory.
237+
212238
## v1.0 — Validated dry-lab-to-wet-lab loop
213239

214240
- independently reviewed assay batch (expert_review.yml GitHub issue template);

‎src/openamp_foundry/cli/main.py‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,17 @@ def build_parser() -> argparse.ArgumentParser:
2929
rank.add_argument("--cert-dir", required=False)
3030
rank.add_argument("--manifest", required=False)
3131
rank.add_argument("--config", default="configs/pipeline.yaml")
32+
rank.add_argument(
33+
"--ranking-mode",
34+
choices=["ensemble", "expert"],
35+
default="ensemble",
36+
help=(
37+
"Ranking mode: 'ensemble' (default, activity-weighted) or "
38+
"'expert' (safety-aware expert composite with selectivity, "
39+
"hemolysis risk, and helix-hinge analysis). Expert mode "
40+
"partially corrects the ensemble's anti-selective bias."
41+
),
42+
)
3243

3344
validate = sub.add_parser("validate", help="Validate a candidate certificate against JSON schema.")
3445
validate.add_argument("--certificate", required=True)
@@ -628,6 +639,7 @@ def main(argv: list[str] | None = None) -> int:
628639
cert_dir=args.cert_dir,
629640
config_path=args.config,
630641
manifest_path=args.manifest,
642+
ranking_mode=getattr(args, "ranking_mode", "ensemble"),
631643
)
632644
print(json.dumps({"status": "ok", "out": args.out, "report": args.report}, indent=2))
633645
return 0

‎src/openamp_foundry/pipeline.py‎

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
from openamp_foundry.scoring.safety import safety_score
1818
from openamp_foundry.scoring.stability import serum_stability_score
1919
from openamp_foundry.scoring.synthesis import synthesis_feasibility_score
20+
from openamp_foundry.scoring.hemolysis import hemolysis_risk_score
2021
from openamp_foundry.selection.diversity import greedy_diverse_select
2122
from openamp_foundry.selection.pareto import rank_candidates
2223
from openamp_foundry.types import ScoredCandidate
@@ -67,8 +68,16 @@ def score_candidates(
6768
# mammalian cytotoxicity. Computed from net charge and GRAVY in compute_features().
6869
# Stored in scores (not just features) so pilot_priority can use it for ranking.
6970
"selectivity_proxy": features.get("selectivity_proxy", 1.0),
71+
"hemolysis_risk": hemolysis_risk_score(features) if valid else 1.0,
7072
}
7173
raw_scores["ensemble"] = ensemble_score(raw_scores, weights)
74+
# Expert composite: safety-aware multi-component score that addresses
75+
# the ensemble's anti-selective bias (triage benchmark: ensemble
76+
# ranks hemolytic > selective; expert composite partially corrects).
77+
# Not used for ranking by default — enabled via --ranking-mode expert.
78+
from openamp_foundry.scoring.expert import expert_score
79+
exp = expert_score(candidate.sequence, features=features)
80+
raw_scores["expert_composite"] = exp.composite
7281
item = ScoredCandidate(
7382
candidate=candidate,
7483
features=features,
@@ -122,6 +131,7 @@ def run_ranking_pipeline(
122131
cert_dir: str | Path | None = None,
123132
config_path: str | Path = "configs/pipeline.yaml",
124133
manifest_path: str | Path | None = None,
134+
ranking_mode: str = "ensemble",
125135
) -> list[ScoredCandidate]:
126136
run_id = str(uuid.uuid4())
127137
generated_at = datetime.now(timezone.utc).isoformat()
@@ -132,7 +142,7 @@ def run_ranking_pipeline(
132142
max_safety_risk = float(selection_cfg.get("max_safety_risk", 1.0))
133143
max_disagreement = float(selection_cfg.get("max_disagreement", 1.0))
134144

135-
ranked = rank_candidates(scored)
145+
ranked = rank_candidates(scored, ranking_mode=ranking_mode)
136146
top_n = int(selection_cfg.get("top_n", len(ranked)))
137147

138148
eligible = [
@@ -171,7 +181,7 @@ def run_ranking_pipeline(
171181

172182
if report_path:
173183
write_report(report_path, ranked, selected)
174-
batch_report = build_batch_report(ranked, selected, generated_at)
184+
batch_report = build_batch_report(ranked, selected, generated_at, ranking_mode=ranking_mode)
175185
report_json = Path(report_path).with_suffix(".json")
176186
write_json(report_json, batch_report)
177187

@@ -204,6 +214,7 @@ def build_batch_report(
204214
ranked: list[ScoredCandidate],
205215
selected: list[ScoredCandidate],
206216
generated_at: str,
217+
ranking_mode: str = "ensemble",
207218
) -> dict[str, Any]:
208219
"""Build a machine-readable batch report validatable against batch_report.schema.json."""
209220
selected_ids = {item.candidate.candidate_id for item in selected}
@@ -232,6 +243,7 @@ def build_batch_report(
232243
) if ranked else 0.0,
233244
},
234245
"selected_ids": sorted(selected_ids),
246+
"ranking_mode": ranking_mode,
235247
}
236248

237249

‎src/openamp_foundry/selection/pareto.py‎

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,30 @@
33
from openamp_foundry.types import ScoredCandidate
44

55

6-
def rank_candidates(candidates: list[ScoredCandidate]) -> list[ScoredCandidate]:
7-
return sorted(candidates, key=lambda c: c.scores.get("ensemble", 0.0), reverse=True)
6+
def rank_candidates(
7+
candidates: list[ScoredCandidate],
8+
ranking_mode: str = "ensemble",
9+
) -> list[ScoredCandidate]:
10+
"""Rank candidates by the specified scoring mode.
811
12+
Args:
13+
candidates: List of ScoredCandidate objects.
14+
ranking_mode: "ensemble" (default) or "expert". The expert mode
15+
ranks by the expert composite score, which incorporates
16+
selectivity, hemolysis risk, and helix-hinge analysis —
17+
partially correcting the ensemble's anti-selective bias
18+
(triage benchmark: ensemble ranks hemolytic > selective).
919
10-
def select_top(candidates: list[ScoredCandidate], top_n: int) -> list[ScoredCandidate]:
11-
return rank_candidates(candidates)[:top_n]
20+
Returns:
21+
Candidates sorted by the chosen score, highest first.
22+
"""
23+
key = "expert_composite" if ranking_mode == "expert" else "ensemble"
24+
return sorted(candidates, key=lambda c: c.scores.get(key, 0.0), reverse=True)
25+
26+
27+
def select_top(
28+
candidates: list[ScoredCandidate],
29+
top_n: int,
30+
ranking_mode: str = "ensemble",
31+
) -> list[ScoredCandidate]:
32+
return rank_candidates(candidates, ranking_mode=ranking_mode)[:top_n]
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
"""Tests for expert composite ranking integration.
2+
3+
Verifies that:
4+
1. score_candidates() now computes expert_composite and hemolysis_risk for every candidate.
5+
2. rank_candidates(ranking_mode='expert') produces a different order than ensemble.
6+
3. The expert composite partially corrects the anti-selective bias.
7+
4. The CLI --ranking-mode expert flag works end-to-end.
8+
5. The batch report records which ranking mode was used.
9+
10+
Context: The triage benchmark (v0.5.12) showed the ensemble has an anti-selective
11+
bias (AUROC selective_vs_hemolytic = 0.466, below random). The expert composite
12+
incorporates selectivity, hemolysis risk, and helix-hinge analysis. This test
13+
verifies the integration is correct, not that the expert composite is biologically
14+
validated (it is not).
15+
"""
16+
from __future__ import annotations
17+
18+
import json
19+
20+
21+
from openamp_foundry.pipeline import score_candidates
22+
from openamp_foundry.selection.pareto import rank_candidates
23+
24+
25+
class TestExpertCompositeInPipeline:
26+
"""Verify expert_composite is computed during scoring."""
27+
28+
def test_expert_composite_present_in_scores(self):
29+
scored, _ = score_candidates("examples/sequences/demo_candidates.csv")
30+
for s in scored:
31+
assert "expert_composite" in s.scores, (
32+
f"{s.candidate.candidate_id} missing expert_composite score"
33+
)
34+
assert 0.0 <= s.scores["expert_composite"] <= 1.0
35+
36+
def test_hemolysis_risk_present_in_scores(self):
37+
scored, _ = score_candidates("examples/sequences/demo_candidates.csv")
38+
for s in scored:
39+
assert "hemolysis_risk" in s.scores, (
40+
f"{s.candidate.candidate_id} missing hemolysis_risk score"
41+
)
42+
assert 0.0 <= s.scores["hemolysis_risk"] <= 1.0
43+
44+
def test_expert_composite_differs_from_ensemble(self):
45+
"""Expert composite should not be identical to ensemble (different formula)."""
46+
scored, _ = score_candidates("examples/sequences/demo_candidates.csv")
47+
for s in scored:
48+
assert s.scores["expert_composite"] != s.scores["ensemble"], (
49+
f"{s.candidate.candidate_id}: expert_composite equals ensemble "
50+
"-- they should differ (different component weights)"
51+
)
52+
53+
54+
class TestExpertRankingMode:
55+
"""Verify ranking_mode='expert' produces different ordering than ensemble."""
56+
57+
def test_expert_ranking_differs_from_ensemble(self):
58+
"""At least one position should differ between ensemble and expert ranking."""
59+
scored, _ = score_candidates("examples/sequences/demo_candidates.csv")
60+
ens_ranked = rank_candidates(scored, ranking_mode="ensemble")
61+
exp_ranked = rank_candidates(scored, ranking_mode="expert")
62+
63+
ens_ids = [s.candidate.candidate_id for s in ens_ranked]
64+
exp_ids = [s.candidate.candidate_id for s in exp_ranked]
65+
66+
# With 10 candidates, at least one position should differ
67+
assert ens_ids != exp_ids, (
68+
"Expert ranking produced identical order to ensemble — "
69+
"the expert composite should change at least some rankings"
70+
)
71+
72+
def test_expert_ranking_sorts_by_expert_composite(self):
73+
"""Expert ranking should sort by expert_composite, highest first."""
74+
scored, _ = score_candidates("examples/sequences/demo_candidates.csv")
75+
exp_ranked = rank_candidates(scored, ranking_mode="expert")
76+
77+
for i in range(len(exp_ranked) - 1):
78+
assert exp_ranked[i].scores["expert_composite"] >= exp_ranked[i + 1].scores["expert_composite"]
79+
80+
def test_ensemble_ranking_unchanged_by_default(self):
81+
"""Default ranking_mode should still be ensemble."""
82+
scored, _ = score_candidates("examples/sequences/demo_candidates.csv")
83+
default_ranked = rank_candidates(scored)
84+
ens_ranked = rank_candidates(scored, ranking_mode="ensemble")
85+
86+
default_ids = [s.candidate.candidate_id for s in default_ranked]
87+
ens_ids = [s.candidate.candidate_id for s in ens_ranked]
88+
assert default_ids == ens_ids
89+
90+
91+
class TestExpertRankingCLI:
92+
"""CLI integration test for --ranking-mode expert."""
93+
94+
def test_cli_expert_ranking_produces_valid_output(self, tmp_path):
95+
from openamp_foundry.cli import main
96+
97+
out = tmp_path / "ranked.jsonl"
98+
report = tmp_path / "report.md"
99+
rc = main([
100+
"rank",
101+
"--candidates", "examples/sequences/demo_candidates.csv",
102+
"--references", "examples/known_reference/demo_known_amps.csv",
103+
"--out", str(out),
104+
"--report", str(report),
105+
"--ranking-mode", "expert",
106+
])
107+
assert rc == 0
108+
assert out.exists()
109+
110+
# Batch report should record the ranking mode
111+
report_json = tmp_path / "report.json"
112+
assert report_json.exists()
113+
data = json.loads(report_json.read_text())
114+
assert data["ranking_mode"] == "expert"
115+
116+
def test_cli_ensemble_ranking_default(self, tmp_path):
117+
"""Default ranking (no --ranking-mode) should be ensemble."""
118+
from openamp_foundry.cli import main
119+
120+
out = tmp_path / "ranked.jsonl"
121+
report = tmp_path / "report.md"
122+
rc = main([
123+
"rank",
124+
"--candidates", "examples/sequences/demo_candidates.csv",
125+
"--references", "examples/known_reference/demo_known_amps.csv",
126+
"--out", str(out),
127+
"--report", str(report),
128+
])
129+
assert rc == 0
130+
report_json = tmp_path / "report.json"
131+
data = json.loads(report_json.read_text())
132+
assert data["ranking_mode"] == "ensemble"
133+
134+
135+
class TestExpertRankingSelectivity:
136+
"""Verify expert ranking shifts top candidates toward safer profiles."""
137+
138+
def test_expert_top_5_have_lower_hemolysis_risk_on_average(self):
139+
"""Expert-ranked top-5 should have lower mean hemolysis_risk than ensemble top-5.
140+
141+
This is the core value proposition: the expert composite incorporates
142+
hemolysis risk, so top-ranked candidates should be safer on average.
143+
If this test fails, the expert composite's hemolysis components are
144+
not effectively influencing the ranking.
145+
"""
146+
scored, _ = score_candidates("examples/benchmark/mixed_candidates.csv")
147+
ens_ranked = rank_candidates(scored, ranking_mode="ensemble")
148+
exp_ranked = rank_candidates(scored, ranking_mode="expert")
149+
150+
ens_top5_hemo = sum(s.scores["hemolysis_risk"] for s in ens_ranked[:5]) / 5
151+
exp_top5_hemo = sum(s.scores["hemolysis_risk"] for s in exp_ranked[:5]) / 5
152+
153+
assert exp_top5_hemo <= ens_top5_hemo, (
154+
f"Expert top-5 mean hemolysis_risk ({exp_top5_hemo:.4f}) should be <= "
155+
f"ensemble top-5 ({ens_top5_hemo:.4f}). The expert composite should "
156+
f"favor safer candidates."
157+
)

0 commit comments

Comments
 (0)