diff --git a/src/openamp_foundry/scoring/ensemble.py b/src/openamp_foundry/scoring/ensemble.py index d75c0d23..e4045e01 100644 --- a/src/openamp_foundry/scoring/ensemble.py +++ b/src/openamp_foundry/scoring/ensemble.py @@ -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) diff --git a/tests/test_pipeline_filters.py b/tests/test_pipeline_filters.py index 12ee3d5b..db037814 100644 --- a/tests/test_pipeline_filters.py +++ b/tests/test_pipeline_filters.py @@ -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). diff --git a/tests/test_scoring_ensemble.py b/tests/test_scoring_ensemble.py index 977269be..a08237b6 100644 --- a/tests/test_scoring_ensemble.py +++ b/tests/test_scoring_ensemble.py @@ -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(