diff --git a/docs/research/NEXT_100_PR_MAP.md b/docs/research/NEXT_100_PR_MAP.md index a7844306..79abd980 100644 --- a/docs/research/NEXT_100_PR_MAP.md +++ b/docs/research/NEXT_100_PR_MAP.md @@ -314,3 +314,4 @@ Make per-family performance gaps visible and machine-checkable, so the pipeline |---:|---|---|---| | Z1 | Add family blindness challenge harness schema (FBH-). | Per-family AUROC + panel representation check; flags when weak AMP classes (AUROC<0.55) are excluded from selected panel; prevents aggregate-metric hiding of family blind spots. | C | | Z2 | Add batch explanation report schema (BXR-). | Per-candidate selection reason tracking (winner_exploit/uncertainty_probe/diversity_anchor/etc.); safety_cleared flag per candidate; verdict (explained/partially_explained/unexplained) based on safety clearance fraction; makes multi-batch selection auditable. | C | +| Z3 | Add adapter registry schema (ARG-). | Machine-readable registry of all external scoring/simulation adapters; adapter_type/status/evidence_level/can_affect_ranking per entry; only active+baseline_verified adapters may affect ranking; blocks experimental/pending adapters from influencing candidate selection. | C | diff --git a/src/openamp_foundry/evidence/adapter_registry.py b/src/openamp_foundry/evidence/adapter_registry.py new file mode 100644 index 00000000..80ee6c82 --- /dev/null +++ b/src/openamp_foundry/evidence/adapter_registry.py @@ -0,0 +1,262 @@ +"""ARG- adapter registry schema. + +Machine-readable registry of all external scoring/simulation adapters. +Tracks adapter_type, status, evidence_level, and whether the adapter +may affect candidate ranking. Only active adapters with baseline_verified +or wet_lab_validated evidence may influence ranking. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +VALID_ARG_ADAPTER_STATUSES: frozenset[str] = frozenset({ + "active", + "deprecated", + "experimental", + "blocked", + "pending_review", +}) + +VALID_ARG_ADAPTER_TYPES: frozenset[str] = frozenset({ + "sequence_scorer", + "structure_predictor", + "toxicity_filter", + "novelty_ranker", + "simulation_runner", + "embedding_provider", +}) + +VALID_ARG_EVIDENCE_LEVELS: frozenset[str] = frozenset({ + "none", + "baseline_claimed", + "baseline_verified", + "wet_lab_validated", +}) + +RANKING_PERMITTED_STATUSES: frozenset[str] = frozenset({"active"}) + +RANKING_PERMITTED_EVIDENCE_LEVELS: frozenset[str] = frozenset({ + "baseline_verified", + "wet_lab_validated", +}) + + +def _compute_can_affect_ranking(status: str, evidence_level: str) -> bool: + return ( + status in RANKING_PERMITTED_STATUSES + and evidence_level in RANKING_PERMITTED_EVIDENCE_LEVELS + ) + + +@dataclass +class AdapterRegistryEntry: + adapter_id: str + adapter_type: str + status: str + evidence_level: str + version: str + scope: str + inputs: list[str] + outputs: list[str] + limitations: list[str] + baseline_ref: str + can_affect_ranking: bool + registered_at: str + + +@dataclass +class AdapterRegistry: + arg_id: str + pipeline_version: str + entries: list[AdapterRegistryEntry] + n_entries: int + n_active: int + n_ranking_permitted: int + n_blocked: int + has_unreviewed_active: bool + created_at: str + dry_lab_only: bool = True + + +def _validate_adapter_registry_entry(entry: AdapterRegistryEntry) -> None: + if not entry.adapter_id: + raise ValueError("adapter_id must be non-empty") + if entry.adapter_type not in VALID_ARG_ADAPTER_TYPES: + raise ValueError( + f"adapter_type {entry.adapter_type!r} not in VALID_ARG_ADAPTER_TYPES" + ) + if entry.status not in VALID_ARG_ADAPTER_STATUSES: + raise ValueError( + f"status {entry.status!r} not in VALID_ARG_ADAPTER_STATUSES" + ) + if entry.evidence_level not in VALID_ARG_EVIDENCE_LEVELS: + raise ValueError( + f"evidence_level {entry.evidence_level!r} " + f"not in VALID_ARG_EVIDENCE_LEVELS" + ) + if not entry.version: + raise ValueError("version must be non-empty") + if not entry.scope: + raise ValueError("scope must be non-empty") + if not entry.inputs: + raise ValueError("inputs must be non-empty") + if not entry.outputs: + raise ValueError("outputs must be non-empty") + if not entry.limitations: + raise ValueError("limitations must be non-empty") + if not entry.registered_at: + raise ValueError("registered_at must be non-empty") + if entry.evidence_level == "baseline_verified": + if not entry.baseline_ref.startswith("CBR-") and not entry.baseline_ref.startswith("SEG-"): + raise ValueError( + f"baseline_ref for baseline_verified evidence must start " + f"with CBR- or SEG-: {entry.baseline_ref!r}" + ) + expected_can_affect = _compute_can_affect_ranking( + entry.status, entry.evidence_level + ) + if entry.can_affect_ranking != expected_can_affect: + raise ValueError( + f"can_affect_ranking mismatch: got {entry.can_affect_ranking}, " + f"expected {expected_can_affect} based on " + f"status={entry.status!r}, evidence_level={entry.evidence_level!r}" + ) + + +def validate_adapter_registry(arg: AdapterRegistry) -> None: + if not arg.arg_id.startswith("ARG-"): + raise ValueError(f"arg_id must start with 'ARG-': {arg.arg_id!r}") + if not arg.pipeline_version: + raise ValueError("pipeline_version must be non-empty") + + for entry in arg.entries: + _validate_adapter_registry_entry(entry) + + if arg.n_entries != len(arg.entries): + raise ValueError( + f"n_entries {arg.n_entries} != " + f"len(entries) {len(arg.entries)}" + ) + + expected_n_active = sum(1 for e in arg.entries if e.status == "active") + if arg.n_active != expected_n_active: + raise ValueError( + f"n_active {arg.n_active} != computed {expected_n_active}" + ) + + expected_n_ranking = sum(1 for e in arg.entries if e.can_affect_ranking) + if arg.n_ranking_permitted != expected_n_ranking: + raise ValueError( + f"n_ranking_permitted {arg.n_ranking_permitted} != " + f"computed {expected_n_ranking}" + ) + + expected_n_blocked = sum(1 for e in arg.entries if e.status == "blocked") + if arg.n_blocked != expected_n_blocked: + raise ValueError( + f"n_blocked {arg.n_blocked} != computed {expected_n_blocked}" + ) + + expected_unreviewed = any( + e.status == "active" and e.evidence_level == "none" + for e in arg.entries + ) + if arg.has_unreviewed_active != expected_unreviewed: + raise ValueError( + f"has_unreviewed_active {arg.has_unreviewed_active} != " + f"computed {expected_unreviewed}" + ) + + if not arg.dry_lab_only: + raise ValueError("dry_lab_only must be True") + if not arg.created_at: + raise ValueError("created_at must be non-empty") + + +def build_adapter_registry( + *, + arg_id: str, + pipeline_version: str, + entries: list[dict | AdapterRegistryEntry], + created_at: str, +) -> AdapterRegistry: + """Build an AdapterRegistry. + + entries: list of dicts (same keys as AdapterRegistryEntry fields) + or AdapterRegistryEntry objects. + """ + registry_entries: list[AdapterRegistryEntry] = [] + for item in entries: + if isinstance(item, AdapterRegistryEntry): + entry = item + else: + d = item + entry = AdapterRegistryEntry( + adapter_id=d["adapter_id"], + adapter_type=d["adapter_type"], + status=d["status"], + evidence_level=d["evidence_level"], + version=d["version"], + scope=d["scope"], + inputs=list(d["inputs"]), + outputs=list(d["outputs"]), + limitations=list(d["limitations"]), + baseline_ref=d.get("baseline_ref", ""), + can_affect_ranking=_compute_can_affect_ranking( + d["status"], d["evidence_level"] + ), + registered_at=d["registered_at"], + ) + entry.can_affect_ranking = _compute_can_affect_ranking( + entry.status, entry.evidence_level + ) + registry_entries.append(entry) + + n_entries = len(registry_entries) + n_active = sum(1 for e in registry_entries if e.status == "active") + n_ranking_permitted = sum(1 for e in registry_entries if e.can_affect_ranking) + n_blocked = sum(1 for e in registry_entries if e.status == "blocked") + has_unreviewed_active = any( + e.status == "active" and e.evidence_level == "none" + for e in registry_entries + ) + + arg = AdapterRegistry( + arg_id=arg_id, + pipeline_version=pipeline_version, + entries=registry_entries, + n_entries=n_entries, + n_active=n_active, + n_ranking_permitted=n_ranking_permitted, + n_blocked=n_blocked, + has_unreviewed_active=has_unreviewed_active, + created_at=created_at, + dry_lab_only=True, + ) + validate_adapter_registry(arg) + return arg + + +def format_adapter_registry(arg: AdapterRegistry) -> str: + lines = [ + f"Adapter Registry — {arg.arg_id}", + f"Pipeline: {arg.pipeline_version}", + f"Entries: {arg.n_entries} total, {arg.n_active} active, " + f"{arg.n_ranking_permitted} ranking-permitted, " + f"{arg.n_blocked} blocked", + ] + if arg.has_unreviewed_active: + lines.append("WARNING: Unreviewed active adapters present") + if arg.entries: + lines.append("Registered adapters:") + for entry in arg.entries: + ranking_flag = "RANKING" if entry.can_affect_ranking else "no_rank" + lines.append( + f" {entry.adapter_id}: type={entry.adapter_type}, " + f"status={entry.status}, evidence={entry.evidence_level}, " + f"{ranking_flag}" + ) + lines.append(f"Created: {arg.created_at}") + lines.append(f"dry_lab_only: {arg.dry_lab_only}") + return "\n".join(lines) diff --git a/tests/evidence/test_adapter_registry.py b/tests/evidence/test_adapter_registry.py new file mode 100644 index 00000000..53265930 --- /dev/null +++ b/tests/evidence/test_adapter_registry.py @@ -0,0 +1,685 @@ +"""Tests for ARG- adapter registry schema.""" + +import pytest +from openamp_foundry.evidence.adapter_registry import ( + AdapterRegistry, + AdapterRegistryEntry, + VALID_ARG_ADAPTER_STATUSES, + VALID_ARG_ADAPTER_TYPES, + VALID_ARG_EVIDENCE_LEVELS, + RANKING_PERMITTED_STATUSES, + RANKING_PERMITTED_EVIDENCE_LEVELS, + build_adapter_registry, + format_adapter_registry, + validate_adapter_registry, +) + +# --------------------------------------------------------------------------- +# Test data +# --------------------------------------------------------------------------- + +_ACTIVE_VERIFIED = [ + { + "adapter_id": "ESMFold-v1", + "adapter_type": "structure_predictor", + "status": "active", + "evidence_level": "baseline_verified", + "version": "1.0.0", + "scope": "Protein structure prediction from sequence", + "inputs": ["amino_acid_sequence"], + "outputs": ["3d_structure_coordinates"], + "limitations": ["Low accuracy for disordered regions"], + "baseline_ref": "CBR-001", + "registered_at": "2026-01-15", + }, +] + +_ACTIVE_NONE = [ + { + "adapter_id": "AMP-BERT-2.0", + "adapter_type": "sequence_scorer", + "status": "active", + "evidence_level": "none", + "version": "2.0.0", + "scope": "AMP probability scoring", + "inputs": ["amino_acid_sequence"], + "outputs": ["amp_probability"], + "limitations": ["Trained on known AMPs only"], + "baseline_ref": "", + "registered_at": "2026-03-01", + }, +] + +_MIXED = [ + { + "adapter_id": "ESMFold-v1", + "adapter_type": "structure_predictor", + "status": "active", + "evidence_level": "baseline_verified", + "version": "1.0.0", + "scope": "Protein structure prediction", + "inputs": ["amino_acid_sequence"], + "outputs": ["3d_structure_coordinates"], + "limitations": ["Low accuracy for disordered regions"], + "baseline_ref": "CBR-001", + "registered_at": "2026-01-15", + }, + { + "adapter_id": "ToxScan-1.0", + "adapter_type": "toxicity_filter", + "status": "experimental", + "evidence_level": "none", + "version": "1.0.0", + "scope": "Hemolysis toxicity screening", + "inputs": ["amino_acid_sequence"], + "outputs": ["toxicity_score"], + "limitations": ["Not validated for clinical use"], + "baseline_ref": "", + "registered_at": "2026-02-10", + }, + { + "adapter_id": "DockRunner", + "adapter_type": "simulation_runner", + "status": "blocked", + "evidence_level": "none", + "version": "0.5.0", + "scope": "Molecular docking simulation", + "inputs": ["amino_acid_sequence"], + "outputs": ["docking_score"], + "limitations": ["Outdated force field parameters"], + "baseline_ref": "", + "registered_at": "2026-01-20", + }, + { + "adapter_id": "NoveltyNet", + "adapter_type": "novelty_ranker", + "status": "active", + "evidence_level": "wet_lab_validated", + "version": "3.1.0", + "scope": "Novelty scoring against known AMP databases", + "inputs": ["amino_acid_sequence"], + "outputs": ["novelty_score"], + "limitations": ["Only compares against APD3 and DRAMP"], + "baseline_ref": "SEG-002", + "registered_at": "2026-04-01", + }, + { + "adapter_id": "OldScorer", + "adapter_type": "sequence_scorer", + "status": "deprecated", + "evidence_level": "baseline_claimed", + "version": "0.9.0", + "scope": "Legacy AMP scoring", + "inputs": ["amino_acid_sequence"], + "outputs": ["amp_score"], + "limitations": ["Replaced by AMP-BERT-2.0"], + "baseline_ref": "", + "registered_at": "2025-06-01", + }, + { + "adapter_id": "Embedder-X", + "adapter_type": "embedding_provider", + "status": "pending_review", + "evidence_level": "none", + "version": "0.1.0", + "scope": "Sequence embedding generation", + "inputs": ["amino_acid_sequence"], + "outputs": ["embedding_vector"], + "limitations": ["Untested on non-AMP sequences"], + "baseline_ref": "", + "registered_at": "2026-05-10", + }, +] + + +def _build(**kwargs): + defaults = dict( + arg_id="ARG-001", + pipeline_version="v3.0", + entries=_ACTIVE_VERIFIED, + created_at="2026-07-10", + ) + defaults.update(kwargs) + return build_adapter_registry(**defaults) + + +# --------------------------------------------------------------------------- +# Section 1: Constants +# --------------------------------------------------------------------------- + + +def test_valid_arg_adapter_statuses_is_frozenset(): + assert isinstance(VALID_ARG_ADAPTER_STATUSES, frozenset) + + +def test_valid_arg_adapter_statuses_contains_active(): + assert "active" in VALID_ARG_ADAPTER_STATUSES + + +def test_valid_arg_adapter_statuses_contains_deprecated(): + assert "deprecated" in VALID_ARG_ADAPTER_STATUSES + + +def test_valid_arg_adapter_statuses_contains_experimental(): + assert "experimental" in VALID_ARG_ADAPTER_STATUSES + + +def test_valid_arg_adapter_statuses_contains_blocked(): + assert "blocked" in VALID_ARG_ADAPTER_STATUSES + + +def test_valid_arg_adapter_statuses_contains_pending_review(): + assert "pending_review" in VALID_ARG_ADAPTER_STATUSES + + +def test_valid_arg_adapter_statuses_size(): + assert len(VALID_ARG_ADAPTER_STATUSES) == 5 + + +def test_valid_arg_adapter_types_is_frozenset(): + assert isinstance(VALID_ARG_ADAPTER_TYPES, frozenset) + + +def test_valid_arg_adapter_types_contains_sequence_scorer(): + assert "sequence_scorer" in VALID_ARG_ADAPTER_TYPES + + +def test_valid_arg_adapter_types_size(): + assert len(VALID_ARG_ADAPTER_TYPES) == 6 + + +def test_valid_arg_evidence_levels_is_frozenset(): + assert isinstance(VALID_ARG_EVIDENCE_LEVELS, frozenset) + + +def test_valid_arg_evidence_levels_contains_baseline_verified(): + assert "baseline_verified" in VALID_ARG_EVIDENCE_LEVELS + + +def test_ranking_permitted_statuses(): + assert RANKING_PERMITTED_STATUSES == frozenset({"active"}) + + +def test_ranking_permitted_evidence_levels(): + assert RANKING_PERMITTED_EVIDENCE_LEVELS == frozenset({ + "baseline_verified", "wet_lab_validated", + }) + + +# --------------------------------------------------------------------------- +# Section 2: build — happy paths +# --------------------------------------------------------------------------- + + +def test_build_returns_adapter_registry(): + assert isinstance(_build(), AdapterRegistry) + + +def test_build_arg_id_stored(): + assert _build().arg_id == "ARG-001" + + +def test_build_pipeline_version_stored(): + assert _build().pipeline_version == "v3.0" + + +def test_build_dry_lab_only_true(): + assert _build().dry_lab_only is True + + +def test_build_n_entries_auto_computed(): + assert _build().n_entries == 1 + + +def test_build_n_active_auto_computed(): + assert _build().n_active == 1 + + +def test_build_n_ranking_permitted_auto_computed(): + assert _build().n_ranking_permitted == 1 + + +def test_build_n_blocked_auto_computed(): + r = _build(entries=_MIXED) + assert r.n_blocked == 1 + + +def test_build_has_unreviewed_active_true(): + r = _build(entries=_ACTIVE_NONE) + assert r.has_unreviewed_active is True + + +def test_build_has_unreviewed_active_false(): + assert _build().has_unreviewed_active is False + + +def test_build_can_affect_ranking_true_active_verified(): + entry = _build().entries[0] + assert entry.can_affect_ranking is True + + +def test_build_can_affect_ranking_false_experimental(): + entry_dict = { + "adapter_id": "TestAdapter", + "adapter_type": "sequence_scorer", + "status": "experimental", + "evidence_level": "none", + "version": "1.0", + "scope": "Test scoring", + "inputs": ["amino_acid_sequence"], + "outputs": ["amp_probability"], + "limitations": ["Test limitation"], + "baseline_ref": "", + "registered_at": "2026-07-10", + } + r = _build(entries=[entry_dict]) + assert r.entries[0].can_affect_ranking is False + + +def test_build_can_affect_ranking_false_active_none_evidence(): + entry_dict = { + "adapter_id": "TestAdapter", + "adapter_type": "sequence_scorer", + "status": "active", + "evidence_level": "none", + "version": "1.0", + "scope": "Test scoring", + "inputs": ["amino_acid_sequence"], + "outputs": ["amp_probability"], + "limitations": ["Test limitation"], + "baseline_ref": "", + "registered_at": "2026-07-10", + } + r = _build(entries=[entry_dict]) + assert r.entries[0].can_affect_ranking is False + + +def test_build_can_affect_ranking_false_blocked(): + entry_dict = { + "adapter_id": "TestAdapter", + "adapter_type": "sequence_scorer", + "status": "blocked", + "evidence_level": "none", + "version": "1.0", + "scope": "Test scoring", + "inputs": ["amino_acid_sequence"], + "outputs": ["amp_probability"], + "limitations": ["Test limitation"], + "baseline_ref": "", + "registered_at": "2026-07-10", + } + r = _build(entries=[entry_dict]) + assert r.entries[0].can_affect_ranking is False + + +def test_build_empty_registry(): + r = _build(entries=[]) + assert r.n_entries == 0 + assert r.n_active == 0 + assert r.n_ranking_permitted == 0 + assert r.n_blocked == 0 + assert r.has_unreviewed_active is False + + +def test_build_all_blocked(): + entries = [ + { + "adapter_id": "A", + "adapter_type": "sequence_scorer", + "status": "blocked", + "evidence_level": "none", + "version": "1.0", + "scope": "Test", + "inputs": ["amino_acid_sequence"], + "outputs": ["amp_probability"], + "limitations": ["Test"], + "baseline_ref": "", + "registered_at": "2026-07-10", + }, + { + "adapter_id": "B", + "adapter_type": "toxicity_filter", + "status": "blocked", + "evidence_level": "none", + "version": "1.0", + "scope": "Test", + "inputs": ["amino_acid_sequence"], + "outputs": ["toxicity_score"], + "limitations": ["Test"], + "baseline_ref": "", + "registered_at": "2026-07-10", + }, + ] + r = _build(entries=entries) + assert r.n_entries == 2 + assert r.n_active == 0 + assert r.n_ranking_permitted == 0 + assert r.n_blocked == 2 + + +def test_build_accepts_entry_objects(): + entry = AdapterRegistryEntry( + adapter_id="ObjAdapter", + adapter_type="novelty_ranker", + status="active", + evidence_level="wet_lab_validated", + version="2.0", + scope="Novelty scoring", + inputs=["amino_acid_sequence"], + outputs=["novelty_score"], + limitations=["Limited database"], + baseline_ref="SEG-005", + can_affect_ranking=True, + registered_at="2026-07-10", + ) + r = _build(entries=[entry]) + assert r.n_entries == 1 + assert r.entries[0].adapter_id == "ObjAdapter" + + +def test_build_mixed_statuses(): + r = _build(entries=_MIXED) + assert r.n_entries == 6 + assert r.n_active == 2 # ESMFold-v1, NoveltyNet + assert r.n_ranking_permitted == 2 # ESMFold-v1, NoveltyNet + assert r.n_blocked == 1 # DockRunner + assert r.has_unreviewed_active is False # both active have verified evidence + + +# --------------------------------------------------------------------------- +# Section 3: validate — rejection cases +# --------------------------------------------------------------------------- + + +def test_validate_rejects_bad_arg_id_prefix(): + with pytest.raises(ValueError, match="ARG-"): + _build(arg_id="BAD-001") + + +def test_validate_rejects_empty_pipeline_version(): + with pytest.raises(ValueError): + _build(pipeline_version="") + + +def test_validate_rejects_empty_adapter_id(): + entries = [ + { + "adapter_id": "", + "adapter_type": "sequence_scorer", + "status": "active", + "evidence_level": "baseline_verified", + "version": "1.0", + "scope": "Test", + "inputs": ["amino_acid_sequence"], + "outputs": ["amp_probability"], + "limitations": ["Test"], + "baseline_ref": "CBR-001", + "registered_at": "2026-07-10", + }, + ] + with pytest.raises(ValueError, match="adapter_id"): + _build(entries=entries) + + +def test_validate_rejects_invalid_adapter_type(): + entries = [ + { + "adapter_id": "Test", + "adapter_type": "invalid_type", + "status": "active", + "evidence_level": "none", + "version": "1.0", + "scope": "Test", + "inputs": ["amino_acid_sequence"], + "outputs": ["amp_probability"], + "limitations": ["Test"], + "baseline_ref": "", + "registered_at": "2026-07-10", + }, + ] + with pytest.raises(ValueError, match="VALID_ARG_ADAPTER_TYPES"): + _build(entries=entries) + + +def test_validate_rejects_invalid_status(): + entries = [ + { + "adapter_id": "Test", + "adapter_type": "sequence_scorer", + "status": "invalid_status", + "evidence_level": "none", + "version": "1.0", + "scope": "Test", + "inputs": ["amino_acid_sequence"], + "outputs": ["amp_probability"], + "limitations": ["Test"], + "baseline_ref": "", + "registered_at": "2026-07-10", + }, + ] + with pytest.raises(ValueError, match="VALID_ARG_ADAPTER_STATUSES"): + _build(entries=entries) + + +def test_validate_rejects_invalid_evidence_level(): + entries = [ + { + "adapter_id": "Test", + "adapter_type": "sequence_scorer", + "status": "active", + "evidence_level": "invalid_level", + "version": "1.0", + "scope": "Test", + "inputs": ["amino_acid_sequence"], + "outputs": ["amp_probability"], + "limitations": ["Test"], + "baseline_ref": "", + "registered_at": "2026-07-10", + }, + ] + with pytest.raises(ValueError, match="VALID_ARG_EVIDENCE_LEVELS"): + _build(entries=entries) + + +def test_validate_rejects_empty_version(): + entries = [ + { + "adapter_id": "Test", + "adapter_type": "sequence_scorer", + "status": "active", + "evidence_level": "none", + "version": "", + "scope": "Test", + "inputs": ["amino_acid_sequence"], + "outputs": ["amp_probability"], + "limitations": ["Test"], + "baseline_ref": "", + "registered_at": "2026-07-10", + }, + ] + with pytest.raises(ValueError, match="version"): + _build(entries=entries) + + +def test_validate_rejects_empty_inputs(): + entries = [ + { + "adapter_id": "Test", + "adapter_type": "sequence_scorer", + "status": "active", + "evidence_level": "none", + "version": "1.0", + "scope": "Test", + "inputs": [], + "outputs": ["amp_probability"], + "limitations": ["Test"], + "baseline_ref": "", + "registered_at": "2026-07-10", + }, + ] + with pytest.raises(ValueError, match="inputs"): + _build(entries=entries) + + +def test_validate_rejects_empty_outputs(): + entries = [ + { + "adapter_id": "Test", + "adapter_type": "sequence_scorer", + "status": "active", + "evidence_level": "none", + "version": "1.0", + "scope": "Test", + "inputs": ["amino_acid_sequence"], + "outputs": [], + "limitations": ["Test"], + "baseline_ref": "", + "registered_at": "2026-07-10", + }, + ] + with pytest.raises(ValueError, match="outputs"): + _build(entries=entries) + + +def test_validate_rejects_empty_limitations(): + entries = [ + { + "adapter_id": "Test", + "adapter_type": "sequence_scorer", + "status": "active", + "evidence_level": "none", + "version": "1.0", + "scope": "Test", + "inputs": ["amino_acid_sequence"], + "outputs": ["amp_probability"], + "limitations": [], + "baseline_ref": "", + "registered_at": "2026-07-10", + }, + ] + with pytest.raises(ValueError, match="limitations"): + _build(entries=entries) + + +def test_validate_rejects_baseline_ref_missing_when_verified(): + entries = [ + { + "adapter_id": "Test", + "adapter_type": "sequence_scorer", + "status": "active", + "evidence_level": "baseline_verified", + "version": "1.0", + "scope": "Test", + "inputs": ["amino_acid_sequence"], + "outputs": ["amp_probability"], + "limitations": ["Test"], + "baseline_ref": "", + "registered_at": "2026-07-10", + }, + ] + with pytest.raises(ValueError, match="baseline_ref"): + _build(entries=entries) + + +def test_validate_rejects_can_affect_ranking_mismatch_true(): + r = _build() + r.entries[0].can_affect_ranking = False + with pytest.raises(ValueError, match="can_affect_ranking"): + validate_adapter_registry(r) + + +def test_validate_rejects_can_affect_ranking_mismatch_false(): + entry_dict = { + "adapter_id": "Test", + "adapter_type": "sequence_scorer", + "status": "active", + "evidence_level": "none", + "version": "1.0", + "scope": "Test", + "inputs": ["amino_acid_sequence"], + "outputs": ["amp_probability"], + "limitations": ["Test"], + "baseline_ref": "", + "registered_at": "2026-07-10", + } + r = _build(entries=[entry_dict]) + r.entries[0].can_affect_ranking = True + with pytest.raises(ValueError, match="can_affect_ranking"): + validate_adapter_registry(r) + + +def test_validate_rejects_dry_lab_only_false(): + r = _build() + r.dry_lab_only = False + with pytest.raises(ValueError, match="dry_lab_only"): + validate_adapter_registry(r) + + +def test_validate_rejects_empty_created_at(): + with pytest.raises(ValueError): + _build(created_at="") + + +def test_validate_rejects_n_entries_mismatch(): + r = _build() + r.n_entries = 999 + with pytest.raises(ValueError, match="n_entries"): + validate_adapter_registry(r) + + +def test_validate_rejects_n_active_mismatch(): + r = _build(entries=_MIXED) + r.n_active = 999 + with pytest.raises(ValueError, match="n_active"): + validate_adapter_registry(r) + + +def test_validate_rejects_has_unreviewed_active_mismatch(): + r = _build() + r.has_unreviewed_active = True + with pytest.raises(ValueError, match="has_unreviewed_active"): + validate_adapter_registry(r) + + +# --------------------------------------------------------------------------- +# Section 4: format +# --------------------------------------------------------------------------- + + +def test_format_contains_arg_id(): + assert "ARG-001" in format_adapter_registry(_build()) + + +def test_format_contains_pipeline_version(): + assert "v3.0" in format_adapter_registry(_build()) + + +def test_format_contains_entry_count(): + text = format_adapter_registry(_build()) + assert "1 total" in text + + +def test_format_contains_active_count(): + text = format_adapter_registry(_build()) + assert "1 active" in text + + +def test_format_contains_ranking_permitted_count(): + text = format_adapter_registry(_build()) + assert "1 ranking-permitted" in text + + +def test_format_contains_blocked_count(): + r = _build(entries=_MIXED) + text = format_adapter_registry(r) + assert "1 blocked" in text + + +def test_format_contains_unreviewed_warning(): + r = _build(entries=_ACTIVE_NONE) + text = format_adapter_registry(r) + assert "WARNING" in text + + +def test_format_is_string(): + assert isinstance(format_adapter_registry(_build()), str) diff --git a/tests/test_test_count_regression.py b/tests/test_test_count_regression.py index 01929473..f5bc171f 100644 --- a/tests/test_test_count_regression.py +++ b/tests/test_test_count_regression.py @@ -4,7 +4,7 @@ import sys import math -BASELINE = 11355 +BASELINE = 11413 def test_test_count_regression():