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
11 changes: 10 additions & 1 deletion src/openamp_foundry/scoring/ensemble.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,18 @@
from __future__ import annotations

import warnings


def ensemble_score(scores: dict[str, float], weights: dict[str, float]) -> float:
missing = [name for name in weights if name not in scores]
if missing:
warnings.warn(
f"ensemble_score: weight keys missing from scores, defaulting to 0.0: {missing}",
UserWarning,
stacklevel=2,
)
total_weight = sum(weights.values()) or 1.0
value = sum(scores[name] * weight for name, weight in weights.items()) / total_weight
value = sum(scores.get(name, 0.0) * weight for name, weight in weights.items()) / total_weight
return round(max(0.0, min(1.0, value)), 4)


Expand Down
34 changes: 34 additions & 0 deletions tests/test_pipeline_filters.py
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,40 @@ def test_novelty_filter_excludes_near_duplicates(tmp_path):
assert rows[0]["scores"]["novelty"] == 0.0


def test_eligibility_novelty_at_exact_minimum_passes(tmp_path):
"""A candidate with novelty exactly = min_novelty (>= boundary) must be selected.

Uses a custom config with min_novelty=0.0 so that even a candidate identical to
the reference (novelty=0.0) passes the filter. Confirms the filter uses >=, not >.
"""
config_path = tmp_path / "permissive.yaml"
config_path.write_text(
"pipeline_version: '0.1.0'\n"
"filters:\n min_length: 8\n max_length: 35\n"
" allowed_amino_acids: 'ACDEFGHIKLMNPQRSTVWY'\n"
"weights:\n activity: 0.40\n safety: 0.25\n synthesis: 0.15\n novelty: 0.20\n"
"selection:\n top_n: 100\n min_novelty: 0.0\n max_safety_risk: 1.0\n"
)
seq = "KWKLFKKIGAVLKVL"
csv = tmp_path / "candidates.csv"
refs = tmp_path / "refs.csv"
# Candidate is identical to the reference → novelty=0.0
csv.write_text(f"id,sequence,source\nTEST-001,{seq},test\n")
refs.write_text(f"id,sequence,source\nREF-001,{seq},reference\n")
out = tmp_path / "ranked.jsonl"
run_ranking_pipeline(
candidate_path=csv,
reference_path=refs,
out_path=out,
config_path=config_path,
)
rows = [json.loads(line) for line in out.read_text().splitlines() if line.strip()]
assert len(rows) == 1
assert rows[0]["scores"]["novelty"] == 0.0
# With min_novelty=0.0, novelty=0.0 satisfies 0.0 >= 0.0 → should be selected
assert rows[0]["selected"] is True


def test_phase3_config_has_stricter_safety_filter_than_pipeline():
"""phase3.yaml max_safety_risk=0.40 (safety>=0.60) vs pipeline.yaml 0.70 (safety>=0.30).

Expand Down
16 changes: 16 additions & 0 deletions tests/test_scoring_ensemble.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,22 @@ def test_returns_rounded_to_4dp(self):
result = ensemble_score(scores, {"activity": 1.0, "safety": 0.0})
assert result == round(result, 4)

def test_missing_score_key_defaults_to_zero_and_warns(self):
# weights has "safety" but scores doesn't: should default to 0.0 AND emit UserWarning
# A missing key could be a scorer failure or a config typo — the warning makes it auditable.
# weighted = (1.0*0.40 + 0.0*0.30) / (0.40+0.30) ≈ 0.5714
import pytest as _pytest
with _pytest.warns(UserWarning, match="safety"):
result = ensemble_score({"activity": 1.0}, {"activity": 0.40, "safety": 0.30})
assert abs(result - 0.5714) < 0.001

def test_extra_score_key_not_in_weights_is_ignored(self):
# boman_activity is in scores but not in weights — should not affect result.
# This pre-existing behavior (only weighted keys matter) is unchanged by the .get() fix.
result_without = ensemble_score({"activity": 1.0}, {"activity": 1.0})
result_with = ensemble_score({"activity": 1.0, "boman_activity": 0.0}, {"activity": 1.0})
assert result_without == result_with


class TestSelectionReasons:
def _scores(
Expand Down
Loading