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
5 changes: 5 additions & 0 deletions schemas/candidate.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand Down
154 changes: 154 additions & 0 deletions tests/test_certificate.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
"""Tests for evidence/certificate.py — build_certificate content and structure."""
from __future__ import annotations

import re

from openamp_foundry.evidence.certificate import build_certificate
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,
}


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_returns_dict(self):
cert = build_certificate(_make_scored(), {}, [])
assert isinstance(cert, dict)
6 changes: 5 additions & 1 deletion tests/test_certificate_schema.py
Original file line number Diff line number Diff line change
@@ -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")
Expand All @@ -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)
100 changes: 100 additions & 0 deletions tests/test_simple_mutation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
"""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):
# 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, (
"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)

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)
Loading