diff --git a/src/openamp_foundry/pipeline.py b/src/openamp_foundry/pipeline.py index f85b3739..3d95a147 100644 --- a/src/openamp_foundry/pipeline.py +++ b/src/openamp_foundry/pipeline.py @@ -15,6 +15,7 @@ from openamp_foundry.scoring.ensemble import ensemble_score, known_failure_modes, selection_reasons from openamp_foundry.scoring.novelty import novelty_score from openamp_foundry.scoring.safety import safety_score +from openamp_foundry.scoring.stability import serum_stability_score from openamp_foundry.scoring.synthesis import synthesis_feasibility_score from openamp_foundry.selection.diversity import greedy_diverse_select from openamp_foundry.selection.pareto import rank_candidates @@ -53,6 +54,7 @@ def score_candidates( synth = synthesis_feasibility_score(features, valid_sequence=valid) nov, nearest = novelty_score(candidate.sequence, references) boman_act = boman_activity_score(candidate.sequence) if valid else 0.0 + stability = serum_stability_score(features) if valid else 0.0 raw_scores = { "activity": act, "safety": safe, @@ -60,6 +62,7 @@ def score_candidates( "novelty": nov, "boman_activity": boman_act, "disagreement": model_disagreement(act, boman_act), + "serum_stability": stability, } raw_scores["ensemble"] = ensemble_score(raw_scores, weights) item = ScoredCandidate( diff --git a/src/openamp_foundry/reports/pilot_panel.py b/src/openamp_foundry/reports/pilot_panel.py index 80e48a98..67445b9b 100644 --- a/src/openamp_foundry/reports/pilot_panel.py +++ b/src/openamp_foundry/reports/pilot_panel.py @@ -16,6 +16,7 @@ "safety", "synthesis", "novelty", + "serum_stability", "pilot_priority", ] @@ -48,6 +49,7 @@ def _f(key: str, fallback: float = 0.0) -> float: "safety": round(_f("safety"), 4), "synthesis": round(_f("synthesis"), 4), "novelty": round(_f("novelty"), 4), + "serum_stability": round(_f("serum_stability"), 4), "pilot_priority": round(_f("pilot_priority"), 4), } @@ -86,13 +88,14 @@ def write_pilot_markdown(panel: list[dict], path: str | Path, generated_at: str "", "## Selection method", "", - "Priority score = `ensemble − 0.3 × disagreement` ", + "Priority score = `ensemble − 0.3 × disagreement + 0.05 × serum_stability` ", "Rules: one representative per seed (highest priority), then remaining slots filled by priority rank.", + "Serum stability acts as a light tiebreaker — favours candidates with fewer interior K/R cleavage sites.", "", "## Candidates", "", - "| # | ID | Sequence | Len | Seed | Ensemble | Activity | Boman | Disagree | Safety | Synth |", - "|--:|---|---|--:|---|---:|---:|---:|---:|---:|---:|", + "| # | ID | Sequence | Len | Seed | Ensemble | Activity | Boman | Disagree | Safety | Synth | SerumStab |", + "|--:|---|---|--:|---|---:|---:|---:|---:|---:|---:|---:|", ] for c in panel: @@ -100,7 +103,7 @@ def write_pilot_markdown(panel: list[dict], path: str | Path, generated_at: str lines.append( f"| {r['pilot_rank']} | {r['candidate_id']} | `{r['sequence']}` | {r['length']} " f"| {r['seed']} | {r['ensemble']} | {r['activity']} | {r['boman_activity']} " - f"| {r['disagreement']} | {r['safety']} | {r['synthesis']} |" + f"| {r['disagreement']} | {r['safety']} | {r['synthesis']} | {r['serum_stability']} |" ) lines += [ diff --git a/src/openamp_foundry/selection/pilot.py b/src/openamp_foundry/selection/pilot.py index 78d52125..e8e9ce10 100644 --- a/src/openamp_foundry/selection/pilot.py +++ b/src/openamp_foundry/selection/pilot.py @@ -14,10 +14,15 @@ def _seed_from_source(source: str) -> str: def _pilot_priority(scores: dict) -> float: - """Higher is better: reward ensemble score, penalise scorer disagreement.""" + """Higher is better: reward ensemble, reward serum stability, penalise disagreement. + + Serum stability is weighted lightly (0.05) so it acts as a tiebreaker without + overriding the ensemble score. A candidate with stability=1.0 gains at most +0.05. + """ ensemble = scores.get("ensemble", 0.0) disagreement = scores.get("disagreement", 0.5) - return round(ensemble - 0.3 * disagreement, 6) + stability = scores.get("serum_stability", 0.5) + return round(ensemble - 0.3 * disagreement + 0.05 * stability, 6) def select_pilot_panel( diff --git a/tests/test_pilot_panel.py b/tests/test_pilot_panel.py index ba739fea..89ddbbcc 100644 --- a/tests/test_pilot_panel.py +++ b/tests/test_pilot_panel.py @@ -81,8 +81,14 @@ def test_lower_disagreement_higher_priority(self): assert p1 > p2 def test_missing_disagreement_defaults_to_0_5(self): + # stability defaults to 0.5 when absent; disagreement defaults to 0.5 p = _pilot_priority({"ensemble": 0.70}) - assert p == pytest.approx(0.70 - 0.3 * 0.5, abs=1e-6) + assert p == pytest.approx(0.70 - 0.3 * 0.5 + 0.05 * 0.5, abs=1e-6) + + def test_higher_stability_higher_priority(self): + p1 = _pilot_priority({"ensemble": 0.70, "disagreement": 0.10, "serum_stability": 1.0}) + p2 = _pilot_priority({"ensemble": 0.70, "disagreement": 0.10, "serum_stability": 0.0}) + assert p1 > p2 class TestSelectPilotPanel: @@ -212,7 +218,7 @@ def test_correct_columns(self, tmp_path): fields = reader.fieldnames expected = {"pilot_rank", "candidate_id", "sequence", "length", "seed", "ensemble", "activity", "boman_activity", "disagreement", - "safety", "synthesis", "novelty", "pilot_priority"} + "safety", "synthesis", "novelty", "serum_stability", "pilot_priority"} assert set(fields) == expected def test_row_count_matches_panel(self, tmp_path):