diff --git a/docs/research/NEXT_100_PR_MAP.md b/docs/research/NEXT_100_PR_MAP.md index f2fda1f4..ae48055e 100644 --- a/docs/research/NEXT_100_PR_MAP.md +++ b/docs/research/NEXT_100_PR_MAP.md @@ -264,8 +264,8 @@ Gate external sharing on all evidence schemas passing; seal the evidence trail f | ID | Task | Why it matters | Priority | |----|------|----------------|----------| -| V1 | Add pre-release audit gateway schema (PRG-). | Single-gate record asserting BRC (batch release checklist), PCC (pipeline completeness), ECI (evidence completeness index) all green; blocks external sharing until all gates pass; required before any batch goes to wet-lab partners or external review. | C | -| V2 | Add evidence bundle manifest schema (EBM-). | Portable listing of all evidence artifact IDs and schema types for a batch; enables reproducibility checks and external handoff; machine-readable index a scientist can use to request any artifact. | C | -| V3 | Add scientific reproducibility seal schema (SRS-). | Immutable record asserting that a batch's evidence trail is complete and auditable; includes pipeline version, schema hash placeholder, and human-reviewed flag; enables preprint data availability statements. | C | -| V4 | Add external review packet schema (ERP-). | Assembles BRC + ECI + FET + PTR + SRS into a single record listing everything a scientist needs to review the batch's computational evidence; dry-lab-only constraint explicit; closes the "what do I send to a reviewer?" question. | C | -| V5 | Add Phase V completeness gate schema (V5G-). | Top-level gate asserting PRG + EBM + SRS + ERP all present; closes Phase V and signals the batch is ready for external scientific review. | C | +| V1 | Add pre-release audit gateway schema (PRG-) (complete). — src/openamp_foundry/evidence/pre_release_audit_gateway.py: 3-gate check (BRC=approved, PCC in A/B, ECI in A/B); 53 tests. | Single-gate record asserting BRC (batch release checklist), PCC (pipeline completeness), ECI (evidence completeness index) all green; blocks external sharing until all gates pass; required before any batch goes to wet-lab partners or external review. | C | +| V2 | Add evidence bundle manifest schema (EBM-) (complete). — src/openamp_foundry/evidence/evidence_bundle_manifest.py: 19 schema types; complete/partial/empty status; 43 tests. | Portable listing of all evidence artifact IDs and schema types for a batch; enables reproducibility checks and external handoff; machine-readable index a scientist can use to request any artifact. | C | +| V3 | Add scientific reproducibility seal schema (SRS-) (complete). — src/openamp_foundry/evidence/scientific_reproducibility_seal.py: sealed/provisional/invalidated statuses; human_reviewed cross-check; PENDING hash placeholder; 50 tests. | Immutable record asserting that a batch's evidence trail is complete and auditable; includes pipeline version, schema hash placeholder, and human-reviewed flag; enables preprint data availability statements. | C | +| V4 | Add external review packet schema (ERP-) (complete). — src/openamp_foundry/evidence/external_review_packet.py: 5 components (BRC/ECI/FET/PTR/SRS); ready/incomplete/draft status; 45 tests. | Assembles BRC + ECI + FET + PTR + SRS into a single record listing everything a scientist needs to review the batch's computational evidence; dry-lab-only constraint explicit; closes the "what do I send to a reviewer?" question. | C | +| V5 | Add Phase V completeness gate schema (V5G-) (complete). — src/openamp_foundry/evidence/phase_v_completeness_gate.py: 4 components (PRG/EBM/SRS/ERP); prefix-validated artifact IDs; ready/blocked verdict; 63 tests. | Top-level gate asserting PRG + EBM + SRS + ERP all present; closes Phase V and signals the batch is ready for external scientific review. | C | diff --git a/src/openamp_foundry/evidence/phase_v_completeness_gate.py b/src/openamp_foundry/evidence/phase_v_completeness_gate.py new file mode 100644 index 00000000..a1d9a288 --- /dev/null +++ b/src/openamp_foundry/evidence/phase_v_completeness_gate.py @@ -0,0 +1,156 @@ +"""V5G- Phase V completeness gate schema. + +Top-level gate asserting that all four Phase V artifacts (PRG, EBM, SRS, ERP) +are present for a batch. A 'ready' verdict signals the batch is cleared for +external scientific review. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +REQUIRED_V5_COMPONENTS: tuple[str, ...] = ("PRG", "EBM", "SRS", "ERP") + +VALID_V5G_VERDICTS: frozenset[str] = frozenset({"ready", "blocked"}) + +_COMPONENT_ID_PREFIXES: dict[str, str] = { + "PRG": "PRG-", + "EBM": "EBM-", + "SRS": "SRS-", + "ERP": "ERP-", +} + + +@dataclass +class V5ComponentCheck: + component_type: str + artifact_id: str + present: bool + + +@dataclass +class PhaseVCompletenessGate: + v5g_id: str + batch_id: str + pipeline_version: str + component_checks: list[V5ComponentCheck] + n_components_required: int + n_components_present: int + missing_component_types: list[str] + gate_verdict: str + dry_lab_only: bool + limitations: list[str] + created_at: str + + +def validate_phase_v_completeness_gate(gate: PhaseVCompletenessGate) -> None: + if not gate.v5g_id.startswith("V5G-"): + raise ValueError(f"v5g_id must start with 'V5G-': {gate.v5g_id!r}") + if not gate.batch_id: + raise ValueError("batch_id must be non-empty") + if not gate.pipeline_version: + raise ValueError("pipeline_version must be non-empty") + seen_types = set() + for check in gate.component_checks: + if check.component_type not in REQUIRED_V5_COMPONENTS: + raise ValueError( + f"component_type {check.component_type!r} not in REQUIRED_V5_COMPONENTS" + ) + if check.component_type in seen_types: + raise ValueError( + f"duplicate component_type: {check.component_type!r}" + ) + seen_types.add(check.component_type) + expected_prefix = _COMPONENT_ID_PREFIXES[check.component_type] + if check.present and not check.artifact_id.startswith(expected_prefix): + raise ValueError( + f"artifact_id for {check.component_type} must start with " + f"'{expected_prefix}': {check.artifact_id!r}" + ) + if gate.n_components_required != len(REQUIRED_V5_COMPONENTS): + raise ValueError( + f"n_components_required must be {len(REQUIRED_V5_COMPONENTS)}" + ) + present_count = sum(1 for c in gate.component_checks if c.present) + if gate.n_components_present != present_count: + raise ValueError("n_components_present mismatch") + expected_missing = sorted( + c.component_type for c in gate.component_checks if not c.present + ) + if gate.missing_component_types != expected_missing: + raise ValueError("missing_component_types mismatch") + if gate.gate_verdict not in VALID_V5G_VERDICTS: + raise ValueError( + f"gate_verdict {gate.gate_verdict!r} not in VALID_V5G_VERDICTS" + ) + if not gate.dry_lab_only: + raise ValueError("dry_lab_only must be True") + if not gate.limitations: + raise ValueError("limitations must be non-empty") + if not gate.created_at: + raise ValueError("created_at must be non-empty") + + +def build_phase_v_completeness_gate( + *, + v5g_id: str, + batch_id: str, + pipeline_version: str, + prg_artifact_id: str = "", + ebm_artifact_id: str = "", + srs_artifact_id: str = "", + erp_artifact_id: str = "", + limitations: list[str], + created_at: str, +) -> PhaseVCompletenessGate: + artifact_map = { + "PRG": prg_artifact_id, + "EBM": ebm_artifact_id, + "SRS": srs_artifact_id, + "ERP": erp_artifact_id, + } + checks = [ + V5ComponentCheck( + component_type=ctype, + artifact_id=artifact_map[ctype], + present=bool(artifact_map[ctype]), + ) + for ctype in REQUIRED_V5_COMPONENTS + ] + n_present = sum(1 for c in checks if c.present) + missing = sorted(c.component_type for c in checks if not c.present) + verdict = "ready" if n_present == len(REQUIRED_V5_COMPONENTS) else "blocked" + gate = PhaseVCompletenessGate( + v5g_id=v5g_id, + batch_id=batch_id, + pipeline_version=pipeline_version, + component_checks=checks, + n_components_required=len(REQUIRED_V5_COMPONENTS), + n_components_present=n_present, + missing_component_types=missing, + gate_verdict=verdict, + dry_lab_only=True, + limitations=limitations, + created_at=created_at, + ) + validate_phase_v_completeness_gate(gate) + return gate + + +def format_phase_v_completeness_gate(gate: PhaseVCompletenessGate) -> str: + lines = [ + f"Phase V Completeness Gate — {gate.v5g_id}", + f"Batch: {gate.batch_id} | Pipeline: {gate.pipeline_version}", + f"Verdict: {gate.gate_verdict} | Present: {gate.n_components_present}/{gate.n_components_required}", + ] + lines.append("Components:") + for check in gate.component_checks: + status = "PRESENT" if check.present else "MISSING" + aid = check.artifact_id if check.artifact_id else "(none)" + lines.append(f" {check.component_type}: {status} {aid}") + if gate.missing_component_types: + lines.append(f"Missing: {', '.join(gate.missing_component_types)}") + lines.append(f"Created: {gate.created_at}") + lines.append(f"Limitations: {'; '.join(gate.limitations)}") + lines.append(f"dry_lab_only: {gate.dry_lab_only}") + return "\n".join(lines) diff --git a/tests/evidence/test_phase_v_completeness_gate.py b/tests/evidence/test_phase_v_completeness_gate.py new file mode 100644 index 00000000..9037845d --- /dev/null +++ b/tests/evidence/test_phase_v_completeness_gate.py @@ -0,0 +1,347 @@ +"""Tests for V5G- Phase V completeness gate schema.""" + +import pytest +from openamp_foundry.evidence.phase_v_completeness_gate import ( + PhaseVCompletenessGate, + V5ComponentCheck, + REQUIRED_V5_COMPONENTS, + VALID_V5G_VERDICTS, + build_phase_v_completeness_gate, + format_phase_v_completeness_gate, + validate_phase_v_completeness_gate, +) + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _build(**kwargs): + defaults = dict( + v5g_id="V5G-001", + batch_id="BATCH-01", + pipeline_version="v1.0", + prg_artifact_id="PRG-001", + ebm_artifact_id="EBM-001", + srs_artifact_id="SRS-001", + erp_artifact_id="ERP-001", + limitations=["dry-lab only"], + created_at="2026-07-10", + ) + defaults.update(kwargs) + return build_phase_v_completeness_gate(**defaults) + + +# --------------------------------------------------------------------------- +# 1. Constants +# --------------------------------------------------------------------------- + + +def test_required_v5_components_is_tuple(): + assert isinstance(REQUIRED_V5_COMPONENTS, tuple) + + +def test_required_v5_components_contains_prg(): + assert "PRG" in REQUIRED_V5_COMPONENTS + + +def test_required_v5_components_contains_ebm(): + assert "EBM" in REQUIRED_V5_COMPONENTS + + +def test_required_v5_components_contains_srs(): + assert "SRS" in REQUIRED_V5_COMPONENTS + + +def test_required_v5_components_contains_erp(): + assert "ERP" in REQUIRED_V5_COMPONENTS + + +def test_required_v5_components_count(): + assert len(REQUIRED_V5_COMPONENTS) == 4 + + +def test_valid_v5g_verdicts_is_frozenset(): + assert isinstance(VALID_V5G_VERDICTS, frozenset) + + +def test_valid_v5g_verdicts_contains_ready(): + assert "ready" in VALID_V5G_VERDICTS + + +def test_valid_v5g_verdicts_contains_blocked(): + assert "blocked" in VALID_V5G_VERDICTS + + +# --------------------------------------------------------------------------- +# 2. build – happy paths +# --------------------------------------------------------------------------- + + +def test_build_returns_phase_v_completeness_gate(): + assert isinstance(_build(), PhaseVCompletenessGate) + + +def test_build_v5g_id_stored(): + assert _build().v5g_id == "V5G-001" + + +def test_build_batch_id_stored(): + assert _build().batch_id == "BATCH-01" + + +def test_build_pipeline_version_stored(): + assert _build().pipeline_version == "v1.0" + + +def test_build_dry_lab_only_true(): + assert _build().dry_lab_only is True + + +def test_build_n_components_required_is_4(): + assert _build().n_components_required == 4 + + +def test_build_all_present_gives_ready(): + assert _build().gate_verdict == "ready" + + +def test_build_all_present_n_components_present_4(): + assert _build().n_components_present == 4 + + +def test_build_all_present_missing_is_empty(): + assert _build().missing_component_types == [] + + +def test_build_component_checks_length(): + assert len(_build().component_checks) == 4 + + +def test_build_component_checks_are_v5_component_check(): + for c in _build().component_checks: + assert isinstance(c, V5ComponentCheck) + + +def test_build_prg_check_present(): + checks = {c.component_type: c for c in _build().component_checks} + assert checks["PRG"].present is True + + +def test_build_ebm_check_present(): + checks = {c.component_type: c for c in _build().component_checks} + assert checks["EBM"].present is True + + +def test_build_srs_check_present(): + checks = {c.component_type: c for c in _build().component_checks} + assert checks["SRS"].present is True + + +def test_build_erp_check_present(): + checks = {c.component_type: c for c in _build().component_checks} + assert checks["ERP"].present is True + + +def test_build_prg_artifact_id_stored(): + checks = {c.component_type: c for c in _build().component_checks} + assert checks["PRG"].artifact_id == "PRG-001" + + +def test_build_ebm_artifact_id_stored(): + checks = {c.component_type: c for c in _build().component_checks} + assert checks["EBM"].artifact_id == "EBM-001" + + +def test_build_srs_artifact_id_stored(): + checks = {c.component_type: c for c in _build().component_checks} + assert checks["SRS"].artifact_id == "SRS-001" + + +def test_build_erp_artifact_id_stored(): + checks = {c.component_type: c for c in _build().component_checks} + assert checks["ERP"].artifact_id == "ERP-001" + + +def test_build_missing_prg_gives_blocked(): + r = _build(prg_artifact_id="") + assert r.gate_verdict == "blocked" + + +def test_build_missing_prg_n_present_3(): + r = _build(prg_artifact_id="") + assert r.n_components_present == 3 + + +def test_build_missing_prg_in_missing_list(): + r = _build(prg_artifact_id="") + assert "PRG" in r.missing_component_types + + +def test_build_missing_ebm_gives_blocked(): + r = _build(ebm_artifact_id="") + assert r.gate_verdict == "blocked" + + +def test_build_missing_srs_gives_blocked(): + r = _build(srs_artifact_id="") + assert r.gate_verdict == "blocked" + + +def test_build_missing_erp_gives_blocked(): + r = _build(erp_artifact_id="") + assert r.gate_verdict == "blocked" + + +def test_build_all_missing_gives_blocked(): + r = _build(prg_artifact_id="", ebm_artifact_id="", srs_artifact_id="", erp_artifact_id="") + assert r.gate_verdict == "blocked" + + +def test_build_all_missing_n_present_0(): + r = _build(prg_artifact_id="", ebm_artifact_id="", srs_artifact_id="", erp_artifact_id="") + assert r.n_components_present == 0 + + +def test_build_all_missing_missing_list_sorted(): + r = _build(prg_artifact_id="", ebm_artifact_id="", srs_artifact_id="", erp_artifact_id="") + assert r.missing_component_types == sorted(r.missing_component_types) + + +def test_build_missing_list_sorted_when_two_missing(): + r = _build(ebm_artifact_id="", srs_artifact_id="") + assert r.missing_component_types == sorted(r.missing_component_types) + + +def test_build_limitations_stored(): + assert _build().limitations == ["dry-lab only"] + + +def test_build_created_at_stored(): + assert _build().created_at == "2026-07-10" + + +# --------------------------------------------------------------------------- +# 3. validate – rejection cases +# --------------------------------------------------------------------------- + + +def test_validate_rejects_bad_v5g_id_prefix(): + with pytest.raises(ValueError, match="V5G-"): + _build(v5g_id="BAD-001") + + +def test_validate_rejects_empty_batch_id(): + with pytest.raises(ValueError): + _build(batch_id="") + + +def test_validate_rejects_empty_pipeline_version(): + with pytest.raises(ValueError): + _build(pipeline_version="") + + +def test_validate_rejects_prg_artifact_without_prefix(): + gate = _build() + gate.component_checks[0].artifact_id = "BAD-001" + with pytest.raises(ValueError): + validate_phase_v_completeness_gate(gate) + + +def test_validate_rejects_invalid_gate_verdict(): + gate = _build() + gate.gate_verdict = "UNKNOWN" + with pytest.raises(ValueError, match="gate_verdict"): + validate_phase_v_completeness_gate(gate) + + +def test_validate_rejects_dry_lab_only_false(): + gate = _build() + gate.dry_lab_only = False + with pytest.raises(ValueError, match="dry_lab_only"): + validate_phase_v_completeness_gate(gate) + + +def test_validate_rejects_empty_limitations(): + with pytest.raises(ValueError, match="limitations"): + _build(limitations=[]) + + +def test_validate_rejects_empty_created_at(): + with pytest.raises(ValueError): + _build(created_at="") + + +def test_validate_rejects_wrong_n_components_required(): + gate = _build() + gate.n_components_required = 3 + with pytest.raises(ValueError, match="n_components_required"): + validate_phase_v_completeness_gate(gate) + + +def test_validate_rejects_n_present_mismatch(): + gate = _build() + gate.n_components_present = 99 + with pytest.raises(ValueError, match="n_components_present"): + validate_phase_v_completeness_gate(gate) + + +def test_validate_rejects_missing_list_mismatch(): + gate = _build() + gate.missing_component_types = ["PRG"] + with pytest.raises(ValueError, match="missing_component_types"): + validate_phase_v_completeness_gate(gate) + + +# --------------------------------------------------------------------------- +# 4. format +# --------------------------------------------------------------------------- + + +def test_format_contains_v5g_id(): + assert "V5G-001" in format_phase_v_completeness_gate(_build()) + + +def test_format_contains_batch_id(): + assert "BATCH-01" in format_phase_v_completeness_gate(_build()) + + +def test_format_contains_verdict_ready(): + assert "ready" in format_phase_v_completeness_gate(_build()) + + +def test_format_contains_prg(): + assert "PRG" in format_phase_v_completeness_gate(_build()) + + +def test_format_contains_ebm(): + assert "EBM" in format_phase_v_completeness_gate(_build()) + + +def test_format_contains_srs(): + assert "SRS" in format_phase_v_completeness_gate(_build()) + + +def test_format_contains_erp(): + assert "ERP" in format_phase_v_completeness_gate(_build()) + + +def test_format_contains_present_label(): + assert "PRESENT" in format_phase_v_completeness_gate(_build()) + + +def test_format_contains_missing_label_when_blocked(): + r = _build(prg_artifact_id="") + assert "MISSING" in format_phase_v_completeness_gate(r) + + +def test_format_contains_limitations(): + assert "dry-lab only" in format_phase_v_completeness_gate(_build()) + + +def test_format_contains_dry_lab_only(): + assert "dry_lab_only: True" in format_phase_v_completeness_gate(_build()) + + +def test_format_is_string(): + assert isinstance(format_phase_v_completeness_gate(_build()), str)