From 3d50d7b2e5545a36fba956a0a01c823978ca8b78 Mon Sep 17 00:00:00 2001 From: j Date: Sun, 28 Jun 2026 08:00:02 +0700 Subject: [PATCH 1/2] test: add certificate content and simple_mutation coverage; fix CWD-dependent schema path - tests/test_certificate.py: 22 new tests for build_certificate content (all fields, hash stability, ISO timestamp, schema validation) - tests/test_simple_mutation.py: 20 new tests for mutate_sequence (edge cases, determinism, canonical AA guarantee, length preservation) - tests/test_certificate_schema.py: anchor schema path to repo root via Path(__file__).parents[1] so test passes regardless of CWD 662 tests total (was 620). --- tests/test_certificate.py | 157 +++++++++++++++++++++++++++++++ tests/test_certificate_schema.py | 6 +- tests/test_simple_mutation.py | 86 +++++++++++++++++ 3 files changed, 248 insertions(+), 1 deletion(-) create mode 100644 tests/test_certificate.py create mode 100644 tests/test_simple_mutation.py diff --git a/tests/test_certificate.py b/tests/test_certificate.py new file mode 100644 index 00000000..d55b20f8 --- /dev/null +++ b/tests/test_certificate.py @@ -0,0 +1,157 @@ +"""Tests for evidence/certificate.py — build_certificate content and structure.""" +from __future__ import annotations + +import re +from pathlib import Path + +from openamp_foundry.evidence.certificate import build_certificate +from openamp_foundry.evidence.schemas import validate_json_schema +from openamp_foundry.features.physchem import compute_features +from openamp_foundry.types import PeptideCandidate, ScoredCandidate + +_CANDIDATE = PeptideCandidate("AMPF-001", "KWKLFKKIGAVLKVL", "test_source") +_FEATURES = compute_features(_CANDIDATE.sequence) +_SCORES = { + "activity": 0.80, + "safety": 0.90, + "synthesis": 0.85, + "novelty": 0.45, + "ensemble": 0.82, +} +_SCHEMA = Path(__file__).parents[1] / "schemas" / "candidate.schema.json" + + +def _make_scored( + candidate: PeptideCandidate = _CANDIDATE, + features: dict | None = None, + scores: dict | None = None, + nearest_reference: dict | None = None, + selection_reason: list[str] | None = None, + known_failure_modes: list[str] | None = None, +) -> ScoredCandidate: + return ScoredCandidate( + candidate=candidate, + features=features if features is not None else _FEATURES, + scores=scores if scores is not None else _SCORES, + nearest_reference=nearest_reference, + selection_reason=selection_reason or ["high ensemble score"], + known_failure_modes=known_failure_modes or ["No wet-lab assay has been run."], + ) + + +class TestBuildCertificateContent: + def test_candidate_id_correct(self): + cert = build_certificate(_make_scored(), {}, []) + assert cert["candidate_id"] == "AMPF-001" + + def test_sequence_correct(self): + cert = build_certificate(_make_scored(), {}, []) + assert cert["sequence"] == "KWKLFKKIGAVLKVL" + + def test_source_correct(self): + cert = build_certificate(_make_scored(), {}, []) + assert cert["source"] == "test_source" + + def test_source_default_is_unknown(self): + cand = PeptideCandidate("TEST-001", "KWKLF") + cert = build_certificate(_make_scored(candidate=cand), {}, []) + assert cert["source"] == "unknown" + + def test_features_passthrough(self): + feat = {"length": 5, "net_charge_proxy": 3} + cert = build_certificate(_make_scored(features=feat), {}, []) + assert cert["features"] == feat + + def test_scores_passthrough(self): + scores = {"activity": 0.75, "ensemble": 0.70} + cert = build_certificate(_make_scored(scores=scores), {}, []) + assert cert["scores"] == scores + + def test_nearest_reference_none_when_not_set(self): + cert = build_certificate(_make_scored(), {}, []) + assert cert["nearest_reference"] is None + + def test_nearest_reference_passthrough(self): + ref = {"id": "REF-001", "similarity": 0.82} + cert = build_certificate(_make_scored(nearest_reference=ref), {}, []) + assert cert["nearest_reference"] == ref + + def test_references_checked_empty_list(self): + cert = build_certificate(_make_scored(), {}, []) + assert cert["references_checked"] == [] + + def test_references_checked_passthrough(self): + refs = ["DRAMP0001", "DRAMP0002"] + cert = build_certificate(_make_scored(), {}, refs) + assert cert["references_checked"] == refs + + def test_selection_reason_passthrough(self): + reasons = ["high activity", "good safety"] + cert = build_certificate(_make_scored(selection_reason=reasons), {}, []) + assert cert["selection_reason"] == reasons + + def test_known_failure_modes_passthrough(self): + modes = ["No wet-lab assay.", "Heuristic only."] + cert = build_certificate(_make_scored(known_failure_modes=modes), {}, []) + assert cert["known_failure_modes"] == modes + + def test_recommended_next_steps_contains_expert_review(self): + cert = build_certificate(_make_scored(), {}, []) + combined = " ".join(cert["recommended_next_steps"]).lower() + assert "expert" in combined or "review" in combined + + def test_recommended_next_steps_contains_no_claim_disclaimer(self): + cert = build_certificate(_make_scored(), {}, []) + combined = " ".join(cert["recommended_next_steps"]).lower() + assert "do not claim" in combined or "without experimental" in combined + + def test_recommended_next_steps_is_non_empty_list(self): + cert = build_certificate(_make_scored(), {}, []) + steps = cert["recommended_next_steps"] + assert isinstance(steps, list) + assert len(steps) >= 1 + + def test_generated_at_is_iso_timestamp(self): + cert = build_certificate(_make_scored(), {}, []) + ts = cert["generated_at"] + assert isinstance(ts, str) + assert "T" in ts + assert re.search(r"(\+00:00|Z)$", ts) is not None + + def test_pipeline_version_is_non_empty_string(self): + cert = build_certificate(_make_scored(), {}, []) + v = cert["pipeline_version"] + assert isinstance(v, str) + assert len(v) > 0 + + def test_config_hash_is_64_char_hex(self): + cert = build_certificate(_make_scored(), {"weights": {}}, []) + h = cert["config_hash"] + assert isinstance(h, str) + assert len(h) == 64 + assert all(c in "0123456789abcdef" for c in h) + + def test_config_hash_stable_for_same_config(self): + config = {"weights": {"activity": 0.4}, "threshold": 0.7} + h1 = build_certificate(_make_scored(), config, [])["config_hash"] + h2 = build_certificate(_make_scored(), config, [])["config_hash"] + assert h1 == h2 + + def test_config_hash_differs_for_different_configs(self): + h1 = build_certificate(_make_scored(), {"k": 1}, [])["config_hash"] + h2 = build_certificate(_make_scored(), {"k": 2}, [])["config_hash"] + assert h1 != h2 + + def test_all_required_keys_present(self): + cert = build_certificate(_make_scored(), {}, []) + required = { + "candidate_id", "sequence", "source", "features", "scores", + "nearest_reference", "references_checked", "selection_reason", + "known_failure_modes", "recommended_next_steps", "generated_at", + "pipeline_version", "config_hash", + } + assert required <= set(cert.keys()) + + def test_schema_validation_passes(self): + cert = build_certificate(_make_scored(), {"weights": {}}, []) + validate_json_schema(cert, _SCHEMA) diff --git a/tests/test_certificate_schema.py b/tests/test_certificate_schema.py index 41a68ee2..89638c43 100644 --- a/tests/test_certificate_schema.py +++ b/tests/test_certificate_schema.py @@ -1,8 +1,12 @@ +from pathlib import Path + from openamp_foundry.evidence.certificate import build_certificate from openamp_foundry.evidence.schemas import validate_json_schema from openamp_foundry.features.physchem import compute_features from openamp_foundry.types import PeptideCandidate, ScoredCandidate +_SCHEMA = Path(__file__).parents[1] / "schemas" / "candidate.schema.json" + def test_certificate_validates(): candidate = PeptideCandidate("AMPF-TEST", "KWKLFKKIGAVLKVL", "test") @@ -14,4 +18,4 @@ def test_certificate_validates(): known_failure_modes=["No wet-lab assay has been run."], ) cert = build_certificate(scored, {"weights": {}}, []) - validate_json_schema(cert, "schemas/candidate.schema.json") + validate_json_schema(cert, _SCHEMA) diff --git a/tests/test_simple_mutation.py b/tests/test_simple_mutation.py new file mode 100644 index 00000000..06d49b31 --- /dev/null +++ b/tests/test_simple_mutation.py @@ -0,0 +1,86 @@ +"""Tests for generators/simple_mutation.py — mutate_sequence.""" +from __future__ import annotations + +from openamp_foundry.generators.simple_mutation import CANONICAL_AA, mutate_sequence + +_SEQ = "KWKLFKKIGAVLKVL" +_CANONICAL = set(CANONICAL_AA) + + +def _hamming(a: str, b: str) -> int: + return sum(c1 != c2 for c1, c2 in zip(a, b)) + + +class TestMutateSequence: + def test_empty_sequence_returns_empty(self): + assert mutate_sequence("") == "" + + def test_empty_with_seed_returns_empty(self): + assert mutate_sequence("", seed=42) == "" + + def test_zero_mutations_returns_original(self): + assert mutate_sequence(_SEQ, mutations=0) == _SEQ + + def test_zero_mutations_with_seed_returns_original(self): + assert mutate_sequence(_SEQ, mutations=0, seed=7) == _SEQ + + def test_negative_mutations_treated_as_zero(self): + assert mutate_sequence(_SEQ, mutations=-5) == _SEQ + + def test_length_preserved_single_mutation(self): + result = mutate_sequence(_SEQ, mutations=1, seed=0) + assert len(result) == len(_SEQ) + + def test_length_preserved_many_mutations(self): + result = mutate_sequence(_SEQ, mutations=10, seed=0) + assert len(result) == len(_SEQ) + + def test_length_preserved_single_char(self): + result = mutate_sequence("K", mutations=5, seed=0) + assert len(result) == 1 + + def test_all_chars_canonical_after_single_mutation(self): + result = mutate_sequence(_SEQ, mutations=1, seed=42) + assert all(c in _CANONICAL for c in result) + + def test_all_chars_canonical_after_many_mutations(self): + result = mutate_sequence(_SEQ, mutations=8, seed=42) + assert all(c in _CANONICAL for c in result) + + def test_single_mutation_changes_at_most_one_position(self): + result = mutate_sequence(_SEQ, mutations=1, seed=7) + assert _hamming(_SEQ, result) <= 1 + + def test_seed_is_deterministic(self): + a = mutate_sequence(_SEQ, mutations=3, seed=99) + b = mutate_sequence(_SEQ, mutations=3, seed=99) + assert a == b + + def test_different_seeds_produce_different_results(self): + results = {mutate_sequence(_SEQ, mutations=1, seed=i) for i in range(100)} + assert len(results) > 1 + + def test_unseeded_calls_can_differ(self): + results = {mutate_sequence(_SEQ, mutations=1) for _ in range(30)} + assert len(results) > 1 + + def test_returns_str(self): + assert isinstance(mutate_sequence(_SEQ), str) + + def test_returns_str_on_empty(self): + assert isinstance(mutate_sequence(""), str) + + def test_canonical_aa_constant_has_20_entries(self): + assert len(CANONICAL_AA) == 20 + + def test_canonical_aa_constant_correct_residues(self): + assert set(CANONICAL_AA) == set("ACDEFGHIKLMNPQRSTVWY") + + def test_single_residue_sequence_returns_valid_aa(self): + result = mutate_sequence("K", mutations=5, seed=0) + assert result in _CANONICAL + + def test_two_char_sequence_length_preserved(self): + result = mutate_sequence("KW", mutations=3, seed=1) + assert len(result) == 2 + assert all(c in _CANONICAL for c in result) From aea41ccc33a7475e8dd54a1e93fc8dba4dab38dc Mon Sep 17 00:00:00 2001 From: j Date: Sun, 28 Jun 2026 08:07:48 +0700 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20address=20review=20feedback=20?= =?UTF-8?q?=E2=80=94=20comment=20unseeded=20test,=20add=20identity-substit?= =?UTF-8?q?ution=20test,=20update=20schema?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - test_unseeded_calls_can_differ: add explanatory comment on entropy source + assertion message - test_single_mutation_can_produce_identity_substitution: new test verifying choice(CANONICAL_AA) can land on the existing residue (Hamming=0 intentional) - schemas/candidate.schema.json: add 'source' (string) and 'nearest_reference' (object | null) to properties; these were emitted by build_certificate but not declared in the schema - tests/test_certificate.py: replace redundant schema-validation test with test_returns_dict; remove unused imports (validate_json_schema, Path) --- schemas/candidate.schema.json | 5 +++++ tests/test_certificate.py | 9 +++------ tests/test_simple_mutation.py | 16 +++++++++++++++- 3 files changed, 23 insertions(+), 7 deletions(-) diff --git a/schemas/candidate.schema.json b/schemas/candidate.schema.json index ec2c2d7b..16c113bf 100644 --- a/schemas/candidate.schema.json +++ b/schemas/candidate.schema.json @@ -16,7 +16,12 @@ "properties": { "candidate_id": {"type": "string"}, "sequence": {"type": "string"}, + "source": {"type": "string", "description": "Dataset or generator that produced this candidate."}, "features": {"type": "object"}, + "nearest_reference": { + "description": "Closest known reference AMP found during novelty check, or null if none found.", + "oneOf": [{"type": "object"}, {"type": "null"}] + }, "scores": { "type": "object", "required": ["activity", "safety", "synthesis", "novelty", "ensemble"], diff --git a/tests/test_certificate.py b/tests/test_certificate.py index d55b20f8..327d3053 100644 --- a/tests/test_certificate.py +++ b/tests/test_certificate.py @@ -2,10 +2,8 @@ from __future__ import annotations import re -from pathlib import Path from openamp_foundry.evidence.certificate import build_certificate -from openamp_foundry.evidence.schemas import validate_json_schema from openamp_foundry.features.physchem import compute_features from openamp_foundry.types import PeptideCandidate, ScoredCandidate @@ -18,7 +16,6 @@ "novelty": 0.45, "ensemble": 0.82, } -_SCHEMA = Path(__file__).parents[1] / "schemas" / "candidate.schema.json" def _make_scored( @@ -152,6 +149,6 @@ def test_all_required_keys_present(self): } assert required <= set(cert.keys()) - def test_schema_validation_passes(self): - cert = build_certificate(_make_scored(), {"weights": {}}, []) - validate_json_schema(cert, _SCHEMA) + def test_returns_dict(self): + cert = build_certificate(_make_scored(), {}, []) + assert isinstance(cert, dict) diff --git a/tests/test_simple_mutation.py b/tests/test_simple_mutation.py index 06d49b31..90c71faa 100644 --- a/tests/test_simple_mutation.py +++ b/tests/test_simple_mutation.py @@ -61,8 +61,22 @@ def test_different_seeds_produce_different_results(self): assert len(results) > 1 def test_unseeded_calls_can_differ(self): + # random.Random(None) seeds from os.urandom() in CPython 3.x. + # With 278+ possible distinct outputs for a 15-mer, P(all 30 identical) is negligible. results = {mutate_sequence(_SEQ, mutations=1) for _ in range(30)} - assert len(results) > 1 + assert len(results) > 1, ( + "Expected at least 2 distinct outputs from 30 unseeded calls; " + "failure indicates entropy source is broken or the function is constant." + ) + + def test_single_mutation_can_produce_identity_substitution(self): + # mutate_sequence picks uniformly from 20 AAs including the original; + # at least one seed among 200 should land on an identity substitution. + found = any( + mutate_sequence(_SEQ, mutations=1, seed=s) == _SEQ + for s in range(200) + ) + assert found, "Expected at least one seed to produce an identity substitution" def test_returns_str(self): assert isinstance(mutate_sequence(_SEQ), str)