From fb3e1ff71a8884753e377b9310a9df6523c2e04b Mon Sep 17 00:00:00 2001 From: j Date: Sun, 28 Jun 2026 08:57:02 +0700 Subject: [PATCH 1/2] fix+test: ensemble missing-key safety, eligibility boundary condition (#24) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ensemble.py: scores[name] → scores.get(name, 0.0) Previously, if a scorer failed to produce a key that appears in weights, ensemble_score would raise KeyError crashing the entire pipeline run. Using .get() degrades gracefully (treats missing scorer as 0 contribution). New tests: - test_missing_score_key_defaults_to_zero: weights has "safety" but scores doesn't → result is weighted average treating missing key as 0.0, not crash - test_extra_score_key_not_in_weights_is_ignored: extra keys in scores are silently ignored, consistent with weighted average semantics - test_eligibility_novelty_at_exact_minimum_passes: verifies the eligibility filter uses >= (not >): novelty=0.0 with min_novelty=0.0 should be selected --- src/openamp_foundry/scoring/ensemble.py | 2 +- tests/test_pipeline_filters.py | 34 +++++++++++++++++++++++++ tests/test_scoring_ensemble.py | 12 +++++++++ 3 files changed, 47 insertions(+), 1 deletion(-) diff --git a/src/openamp_foundry/scoring/ensemble.py b/src/openamp_foundry/scoring/ensemble.py index d75c0d23..74aa85af 100644 --- a/src/openamp_foundry/scoring/ensemble.py +++ b/src/openamp_foundry/scoring/ensemble.py @@ -3,7 +3,7 @@ def ensemble_score(scores: dict[str, float], weights: dict[str, float]) -> float: 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..6ee34356 100644 --- a/tests/test_scoring_ensemble.py +++ b/tests/test_scoring_ensemble.py @@ -56,6 +56,18 @@ 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(self): + # weights has "safety" but scores doesn't — should default to 0.0, not raise KeyError + # weighted = (1.0*0.40 + 0.0*0.30) / (0.40+0.30) ≈ 0.5714 + 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 + 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( From 1af045dd9e3635674d31893ddf6b19e02ddebc25 Mon Sep 17 00:00:00 2001 From: j Date: Sun, 28 Jun 2026 09:01:44 +0700 Subject: [PATCH 2/2] fix: emit UserWarning when ensemble weight key missing from scores MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A missing key was silently treated as 0.0, which would hide YAML config typos (e.g. 'activty' → correct score 0.91 vs wrong score 0.34). Now emits warnings.warn(UserWarning) listing the missing keys so the issue is auditable in logs without crashing. Test updated to use pytest.warns() to assert the warning is actually emitted. --- src/openamp_foundry/scoring/ensemble.py | 9 +++++++++ tests/test_scoring_ensemble.py | 12 ++++++++---- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/src/openamp_foundry/scoring/ensemble.py b/src/openamp_foundry/scoring/ensemble.py index 74aa85af..e4045e01 100644 --- a/src/openamp_foundry/scoring/ensemble.py +++ b/src/openamp_foundry/scoring/ensemble.py @@ -1,7 +1,16 @@ 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.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_scoring_ensemble.py b/tests/test_scoring_ensemble.py index 6ee34356..a08237b6 100644 --- a/tests/test_scoring_ensemble.py +++ b/tests/test_scoring_ensemble.py @@ -56,14 +56,18 @@ 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(self): - # weights has "safety" but scores doesn't — should default to 0.0, not raise KeyError + 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 - result = ensemble_score({"activity": 1.0}, {"activity": 0.40, "safety": 0.30}) + 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 + # 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