diff --git a/docs/evidence/CERTIFICATE_CLAIM_BOUNDARY_GUIDE.md b/docs/evidence/CERTIFICATE_CLAIM_BOUNDARY_GUIDE.md new file mode 100644 index 00000000..38af152e --- /dev/null +++ b/docs/evidence/CERTIFICATE_CLAIM_BOUNDARY_GUIDE.md @@ -0,0 +1,65 @@ +# CertificateClaimBoundary (CCB-) Guide + +## Purpose + +`CertificateClaimBoundary` documents what a certificate does NOT prove. It prevents +score-to-proof drift by requiring explicit enumeration of unsupported claim classes +before a certificate can be shared externally. + +This is the negative complement of `ProofLadderLevelCertificate` (PLC-): while PLC- +asserts what level IS supported, CCB- explicitly asserts what is NOT. + +## Claim Classes (8 total) + +| Class | Description | +|-------|-------------| +| `biological_activity` | Actual in-vitro antimicrobial activity | +| `human_safety` | Safety for human use or administration | +| `clinical_utility` | Usefulness in clinical settings | +| `animal_efficacy` | Efficacy in animal models | +| `therapeutic_indication` | Indication for therapeutic use | +| `regulatory_approval` | Any regulatory or market approval | +| `mechanism_proof` | Proven mechanism of action | +| `resistance_profile` | Actual resistance/susceptibility profile | + +## Schema Fields + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `ccb_id` | str | yes | Unique identifier, must start with `CCB-` | +| `pipeline_version` | str | yes | Pipeline version | +| `certificate_id` | str | yes | Source certificate ID | +| `candidate_id` | str | yes | Candidate being assessed | +| `boundary_date` | str | yes | ISO date (YYYY-MM-DD) | +| `unsupported_claim_classes` | list | yes | ≥3 claim classes from controlled vocabulary | +| `boundary_statement` | str | yes | Human-readable statement (≤600 chars) | +| `dry_lab_only` | bool | yes | Must be True | +| `all_listed_classes_unsupported` | bool | yes | Must be True | +| `notes` | str | no | Additional context (≤300 chars) | + +## Validation Rules + +1. `ccb_id` starts with `CCB-` +2. `pipeline_version` non-empty +3. `certificate_id` non-empty +4. `candidate_id` non-empty +5. `boundary_date` is ISO date (YYYY-MM-DD) +6. `unsupported_claim_classes` non-empty +7. All listed classes in controlled vocabulary (8 values) +8. At least 3 valid claim classes listed +9. `boundary_statement` non-empty and ≤600 chars +10. `dry_lab_only` must be True +11. `all_listed_classes_unsupported` must be True +12. No duplicate claim classes +13. `notes` ≤300 chars + +## Warnings + +- Not all 8 claim classes listed: reports how many are missing +- `notes` empty: consider documenting context + +## Integration + +- Pairs with: `ProofLadderLevelCertificate` (PLC-) as negative complement +- Required for: external review packets, partner sharing +- Closes: Phase B B2 — score-to-proof drift prevention diff --git a/docs/evidence/METRICS_CURRENT.md b/docs/evidence/METRICS_CURRENT.md index f687f675..73aabfcb 100644 --- a/docs/evidence/METRICS_CURRENT.md +++ b/docs/evidence/METRICS_CURRENT.md @@ -9,6 +9,14 @@ Machine-readable snapshot: `outputs/metrics_snapshot.json` regenerated with `mak ## Changelog +### v0.10.27 — Phase B B2: CertificateClaimBoundary schema +- Added `CertificateClaimBoundary` (CCB-) schema +- 10 fields: ccb_id, pipeline_version, certificate_id, candidate_id, boundary_date, unsupported_claim_classes (8-value vocab), boundary_statement, dry_lab_only, all_listed_classes_unsupported, notes +- 13 validation rules; 2 warnings; 63 tests +- BASELINE 6268→6331 +- CLI: openamp-foundry certificate-claim-boundary-check +- Closes Phase B B2 — negative complement of PLC-; prevents score-to-proof drift by requiring explicit enumeration of unsupported claims + ### v0.10.26 — Phase B B1: ProofLadderLevelCertificate schema - Added `ProofLadderLevelCertificate` (PLC-) schema - 14 fields: plc_id, pipeline_version, candidate_id, certificate_id, claimed_level, evidence_type, verifier_type, verification_date, supporting_artifact_ids, unsupported_claims, human_review_required, human_review_completed, dry_lab_only, notes diff --git a/docs/research/NEXT_100_PR_MAP.md b/docs/research/NEXT_100_PR_MAP.md index af0ef083..3149d060 100644 --- a/docs/research/NEXT_100_PR_MAP.md +++ b/docs/research/NEXT_100_PR_MAP.md @@ -42,7 +42,7 @@ Make candidate evidence packages impossible to confuse with biological proof. | PR | Task | Why it matters | Review class | |---:|---|---|---| | B1 | Add certificate field for `proof_ladder_level` (complete). | PLC- schema: 14 fields, 14 validation rules, dry_lab_only caps level at multi_signal_candidate_evidence, human_review_required enforced for levels ≥expert_reviewed_assay_proposal, unsupported_claims required as anti-overclaim guard; makes level claim machine-verifiable. | B | -| B2 | Add `unsupported_claims` field to certificates. | Prevents score-to-proof drift. | B | +| B2 | Add `unsupported_claims` field to certificates (complete). | CCB- schema: 10 fields, 13 validation rules, 8 claim class vocabulary, ≥3 classes required, dry_lab_only+all_listed_classes_unsupported enforced, no-duplicate check; negative complement of PLC- closes score-to-proof drift gap. | B | | B3 | Add `baseline_caveat` field. | Forces cheap-explanation visibility. | B | | B4 | Add `release_status` field. | Supports staged release. | C/D | | B5 | Add certificate quality-tier validator. | External-review readiness becomes measurable. | B | diff --git a/src/openamp_foundry/cli/commands/reports.py b/src/openamp_foundry/cli/commands/reports.py index 6894aed1..d3af1f7b 100644 --- a/src/openamp_foundry/cli/commands/reports.py +++ b/src/openamp_foundry/cli/commands/reports.py @@ -2614,6 +2614,23 @@ def _run_rotation_plan_check(args: argparse.Namespace) -> int: return 0 if result.passed else 3 +def _run_certificate_claim_boundary_check(args): + """CLI handler for certificate-claim-boundary-check.""" + import json, sys + from openamp_foundry.evidence.certificate_claim_boundary import validate_dict + data = json.load(open(args.input)) + issues = validate_dict(data) + errors = [i for i in issues if not i.startswith("WARNING:")] + warnings = [i for i in issues if i.startswith("WARNING:")] + for w in warnings: + print(w) + if errors: + for e in errors: + print(f"ERROR: {e}", file=sys.stderr) + sys.exit(1) + print("OK: CertificateClaimBoundary is valid.") + + def _run_security_report_check(args: argparse.Namespace) -> int: """Validate a security vulnerability report.""" import json as _json diff --git a/src/openamp_foundry/cli/main.py b/src/openamp_foundry/cli/main.py index 50830d3f..55fca8ef 100644 --- a/src/openamp_foundry/cli/main.py +++ b/src/openamp_foundry/cli/main.py @@ -41,6 +41,7 @@ _run_synthetic_boundary_audit_record_check, _run_expert_review_example_package_check, _run_proof_ladder_level_certificate_check, + _run_certificate_claim_boundary_check, ) from openamp_foundry.cli.commands.gates import _run_gate_check, _run_release_gate_check from openamp_foundry.cli.commands.reports import _run_contribution_check @@ -2553,6 +2554,10 @@ def build_parser() -> argparse.ArgumentParser: p_plc.add_argument("input", help="Path to JSON file") p_plc.set_defaults(func=_run_proof_ladder_level_certificate_check) + p_ccb = subparsers.add_parser("certificate-claim-boundary-check", help="Validate a CertificateClaimBoundary JSON") + p_ccb.add_argument("input", help="Path to JSON file") + p_ccb.set_defaults(func=_run_certificate_claim_boundary_check) + # ── Selection rationale check (Phase K K1) ─────────────────────── src2 = sub.add_parser( "selection-rationale-check", diff --git a/src/openamp_foundry/evidence/certificate_claim_boundary.py b/src/openamp_foundry/evidence/certificate_claim_boundary.py new file mode 100644 index 00000000..1f2b2e41 --- /dev/null +++ b/src/openamp_foundry/evidence/certificate_claim_boundary.py @@ -0,0 +1,178 @@ +"""Certificate claim boundary schema — Phase B B2. + +Documents what a certificate's scores do NOT prove. Prevents score-to-proof +drift by requiring explicit enumeration of unsupported claim classes before +a certificate can be shared externally. + +This schema is the negative complement of ProofLadderLevelCertificate (PLC-): +while PLC- asserts what level IS supported, CCB- explicitly asserts what is NOT. + +Claim classes (the things a dry-lab certificate cannot support): + biological_activity — actual in-vitro antimicrobial activity + human_safety — safety for human use or administration + clinical_utility — usefulness in clinical settings + animal_efficacy — efficacy in animal models + therapeutic_indication — indication for therapeutic use + regulatory_approval — any regulatory or market approval + mechanism_proof — proven mechanism of action + resistance_profile — actual resistance/susceptibility profile +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass, field +from typing import List + +CCB_PREFIX = "CCB-" + +ALL_CLAIM_CLASSES = frozenset({ + "biological_activity", + "human_safety", + "clinical_utility", + "animal_efficacy", + "therapeutic_indication", + "regulatory_approval", + "mechanism_proof", + "resistance_profile", +}) + +MINIMUM_UNSUPPORTED_CLASSES = 3 +BOUNDARY_STATEMENT_MAX_LENGTH = 600 +NOTES_MAX_LENGTH = 300 +_ISO_DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$") + + +@dataclass +class CertificateClaimBoundary: + ccb_id: str + pipeline_version: str + certificate_id: str + candidate_id: str + boundary_date: str + unsupported_claim_classes: List[str] + boundary_statement: str + dry_lab_only: bool + all_listed_classes_unsupported: bool + notes: str = "" + + +def validate(boundary: CertificateClaimBoundary) -> List[str]: + """Return list of error strings; empty list means valid.""" + errors: List[str] = [] + + # Rule 1: ID prefix + if not boundary.ccb_id.startswith(CCB_PREFIX): + errors.append( + f"ccb_id must start with '{CCB_PREFIX}', got: {boundary.ccb_id!r}" + ) + + # Rule 2: pipeline_version non-empty + if not boundary.pipeline_version.strip(): + errors.append("pipeline_version must not be empty") + + # Rule 3: certificate_id non-empty + if not boundary.certificate_id.strip(): + errors.append("certificate_id must not be empty") + + # Rule 4: candidate_id non-empty + if not boundary.candidate_id.strip(): + errors.append("candidate_id must not be empty") + + # Rule 5: boundary_date ISO format + if not _ISO_DATE_RE.match(boundary.boundary_date): + errors.append( + f"boundary_date must be YYYY-MM-DD, got: {boundary.boundary_date!r}" + ) + + # Rule 6: unsupported_claim_classes must be non-empty list + if not boundary.unsupported_claim_classes: + errors.append( + "unsupported_claim_classes must not be empty — " + "at least one claim class must be listed as unsupported" + ) + + # Rule 7: each claim class must be in ALL_CLAIM_CLASSES + invalid_classes = [ + c for c in boundary.unsupported_claim_classes + if c not in ALL_CLAIM_CLASSES + ] + if invalid_classes: + errors.append( + f"unsupported_claim_classes contains invalid values: {invalid_classes}; " + f"valid classes are {sorted(ALL_CLAIM_CLASSES)}" + ) + + # Rule 8: minimum unsupported classes + valid_listed = [c for c in boundary.unsupported_claim_classes if c in ALL_CLAIM_CLASSES] + if len(valid_listed) < MINIMUM_UNSUPPORTED_CLASSES: + errors.append( + f"unsupported_claim_classes must list at least " + f"{MINIMUM_UNSUPPORTED_CLASSES} valid claim classes; " + f"got {len(valid_listed)}" + ) + + # Rule 9: boundary_statement non-empty and length + if not boundary.boundary_statement.strip(): + errors.append("boundary_statement must not be empty") + elif len(boundary.boundary_statement) > BOUNDARY_STATEMENT_MAX_LENGTH: + errors.append( + f"boundary_statement exceeds {BOUNDARY_STATEMENT_MAX_LENGTH} chars " + f"(got {len(boundary.boundary_statement)})" + ) + + # Rule 10: dry_lab_only must be True for pipeline-generated boundaries + if not boundary.dry_lab_only: + errors.append( + "dry_lab_only must be True — certificate claim boundaries are " + "issued for dry-lab certificates only" + ) + + # Rule 11: all_listed_classes_unsupported must be True + if not boundary.all_listed_classes_unsupported: + errors.append( + "all_listed_classes_unsupported must be True — all listed claim " + "classes are verified as unsupported by this certificate" + ) + + # Rule 12: no duplicate claim classes + seen = set() + duplicates = [] + for c in boundary.unsupported_claim_classes: + if c in seen: + duplicates.append(c) + seen.add(c) + if duplicates: + errors.append( + f"unsupported_claim_classes contains duplicates: {duplicates}" + ) + + # Rule 13: notes length + if len(boundary.notes) > NOTES_MAX_LENGTH: + errors.append( + f"notes exceeds {NOTES_MAX_LENGTH} chars (got {len(boundary.notes)})" + ) + + # Warnings + if len(valid_listed) < len(ALL_CLAIM_CLASSES): + missing = sorted(ALL_CLAIM_CLASSES - set(boundary.unsupported_claim_classes)) + errors.append( + f"WARNING: {len(missing)} claim class(es) not listed as unsupported: " + f"{missing} — consider whether these are truly supported by this certificate" + ) + if not boundary.notes.strip(): + errors.append( + "WARNING: notes is empty — consider documenting why specific " + "claim classes are not listed" + ) + + return errors + + +def validate_dict(data: dict) -> List[str]: + """Validate a plain dict by constructing CertificateClaimBoundary first.""" + try: + boundary = CertificateClaimBoundary(**data) + except TypeError as exc: + return [f"Schema construction error: {exc}"] + return validate(boundary) diff --git a/tests/evidence/test_certificate_claim_boundary.py b/tests/evidence/test_certificate_claim_boundary.py new file mode 100644 index 00000000..ebeb5d97 --- /dev/null +++ b/tests/evidence/test_certificate_claim_boundary.py @@ -0,0 +1,475 @@ +"""Tests for CertificateClaimBoundary schema — Phase B B2. + +Exactly 63 tests: valid baseline + each validation rule + edge cases + warnings. +""" + +from __future__ import annotations + +import pytest + +from openamp_foundry.evidence.certificate_claim_boundary import ( + ALL_CLAIM_CLASSES, + BOUNDARY_STATEMENT_MAX_LENGTH, + CCB_PREFIX, + MINIMUM_UNSUPPORTED_CLASSES, + NOTES_MAX_LENGTH, + CertificateClaimBoundary, + validate, + validate_dict, +) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _all_classes() -> list: + return sorted(ALL_CLAIM_CLASSES) + + +def _valid_boundary(**overrides) -> CertificateClaimBoundary: + defaults = dict( + ccb_id="CCB-20240315-001", + pipeline_version="v2.1.0", + certificate_id="CERT-20240315-001", + candidate_id="AMPF-001", + boundary_date="2024-03-15", + unsupported_claim_classes=_all_classes(), + boundary_statement=( + "This certificate does not support claims of biological activity, " + "human safety, clinical utility, animal efficacy, therapeutic indication, " + "regulatory approval, mechanism proof, or resistance profile. " + "All outputs are computational dry-lab predictions only." + ), + dry_lab_only=True, + all_listed_classes_unsupported=True, + notes="", + ) + defaults.update(overrides) + return CertificateClaimBoundary(**defaults) + + +def _valid() -> CertificateClaimBoundary: + return _valid_boundary() + + +def _errors(b): + return [e for e in validate(b) if not e.startswith("WARNING:")] + + +def _warns(b): + return [e for e in validate(b) if e.startswith("WARNING:")] + + +# --------------------------------------------------------------------------- +# Group 1: Valid baseline (3 tests) +# --------------------------------------------------------------------------- + +class TestValidBaseline: + def test_valid_returns_no_errors(self): + assert _errors(_valid()) == [] + + def test_valid_with_notes(self): + b = _valid_boundary(notes="Issued for external review packet.") + assert _errors(b) == [] + + def test_valid_minimum_classes(self): + b = _valid_boundary( + unsupported_claim_classes=[ + "biological_activity", "human_safety", "clinical_utility" + ] + ) + assert _errors(b) == [] + + +# --------------------------------------------------------------------------- +# Group 2: Rule 1 — ccb_id prefix (4 tests) +# --------------------------------------------------------------------------- + +class TestCcbIdPrefix: + def test_wrong_prefix_rejected(self): + b = _valid_boundary(ccb_id="PLC-001") + assert any("ccb_id" in e for e in _errors(b)) + + def test_lowercase_prefix_rejected(self): + b = _valid_boundary(ccb_id="ccb-001") + assert any("ccb_id" in e for e in _errors(b)) + + def test_no_prefix_rejected(self): + b = _valid_boundary(ccb_id="001") + assert any("ccb_id" in e for e in _errors(b)) + + def test_correct_prefix_accepted(self): + b = _valid_boundary(ccb_id="CCB-2024-001") + assert _errors(b) == [] + + +# --------------------------------------------------------------------------- +# Group 3: Rules 2-4 — pipeline_version, certificate_id, candidate_id (4 tests) +# --------------------------------------------------------------------------- + +class TestIdentifierFields: + def test_empty_pipeline_version_rejected(self): + b = _valid_boundary(pipeline_version="") + assert any("pipeline_version" in e for e in _errors(b)) + + def test_empty_certificate_id_rejected(self): + b = _valid_boundary(certificate_id="") + assert any("certificate_id" in e for e in _errors(b)) + + def test_empty_candidate_id_rejected(self): + b = _valid_boundary(candidate_id="") + assert any("candidate_id" in e for e in _errors(b)) + + def test_all_identifiers_valid(self): + b = _valid_boundary( + pipeline_version="v3.0.0", + certificate_id="CERT-FINAL-001", + candidate_id="AMPF-999", + ) + assert _errors(b) == [] + + +# --------------------------------------------------------------------------- +# Group 4: Rule 5 — boundary_date ISO format (3 tests) +# --------------------------------------------------------------------------- + +class TestBoundaryDate: + def test_invalid_format_rejected(self): + b = _valid_boundary(boundary_date="March 15, 2024") + assert any("boundary_date" in e for e in _errors(b)) + + def test_wrong_separator_rejected(self): + b = _valid_boundary(boundary_date="2024/03/15") + assert any("boundary_date" in e for e in _errors(b)) + + def test_valid_iso_date(self): + b = _valid_boundary(boundary_date="2025-01-01") + assert _errors(b) == [] + + +# --------------------------------------------------------------------------- +# Group 5: Rule 6 — unsupported_claim_classes non-empty (3 tests) +# --------------------------------------------------------------------------- + +class TestClaimClassesNonEmpty: + def test_empty_list_rejected(self): + b = _valid_boundary(unsupported_claim_classes=[]) + assert any("unsupported_claim_classes" in e for e in _errors(b)) + + def test_single_class_below_minimum_rejected(self): + b = _valid_boundary(unsupported_claim_classes=["biological_activity"]) + # Should fail Rule 8 (minimum 3) + errs = _errors(b) + assert any("at least" in e for e in errs) + + def test_three_classes_accepted(self): + b = _valid_boundary( + unsupported_claim_classes=[ + "biological_activity", "human_safety", "clinical_utility" + ] + ) + assert _errors(b) == [] + + +# --------------------------------------------------------------------------- +# Group 6: Rule 7 — valid claim class vocabulary (4 tests) +# --------------------------------------------------------------------------- + +class TestClaimClassVocabulary: + def test_invalid_class_rejected(self): + b = _valid_boundary( + unsupported_claim_classes=[ + "biological_activity", "human_safety", "fake_class" + ] + ) + assert any("invalid values" in e for e in _errors(b)) + + def test_all_valid_classes_accepted(self): + b = _valid_boundary(unsupported_claim_classes=_all_classes()) + assert _errors(b) == [] + + @pytest.mark.parametrize("cls", sorted(ALL_CLAIM_CLASSES)) + def test_each_class_individually_valid(self, cls): + classes = sorted(ALL_CLAIM_CLASSES) + b = _valid_boundary(unsupported_claim_classes=classes) + assert _errors(b) == [] + + def test_empty_string_class_rejected(self): + b = _valid_boundary( + unsupported_claim_classes=[ + "biological_activity", "human_safety", "clinical_utility", "" + ] + ) + assert any("invalid values" in e for e in _errors(b)) + + +# --------------------------------------------------------------------------- +# Group 7: Rule 8 — minimum unsupported classes (3 tests) +# --------------------------------------------------------------------------- + +class TestMinimumClasses: + def test_two_classes_rejected(self): + b = _valid_boundary( + unsupported_claim_classes=["biological_activity", "human_safety"] + ) + assert any("at least" in e for e in _errors(b)) + + def test_exactly_minimum_accepted(self): + b = _valid_boundary( + unsupported_claim_classes=[ + "biological_activity", "human_safety", "clinical_utility" + ] + ) + assert _errors(b) == [] + + def test_minimum_constant(self): + assert MINIMUM_UNSUPPORTED_CLASSES == 3 + + +# --------------------------------------------------------------------------- +# Group 8: Rule 9 — boundary_statement (3 tests) +# --------------------------------------------------------------------------- + +class TestBoundaryStatement: + def test_empty_rejected(self): + b = _valid_boundary(boundary_statement="") + assert any("boundary_statement" in e for e in _errors(b)) + + def test_too_long_rejected(self): + b = _valid_boundary(boundary_statement="x" * (BOUNDARY_STATEMENT_MAX_LENGTH + 1)) + assert any("boundary_statement" in e for e in _errors(b)) + + def test_at_limit_accepted(self): + b = _valid_boundary(boundary_statement="x" * BOUNDARY_STATEMENT_MAX_LENGTH) + assert _errors(b) == [] + + +# --------------------------------------------------------------------------- +# Group 9: Rule 10 — dry_lab_only (3 tests) +# --------------------------------------------------------------------------- + +class TestDryLabOnly: + def test_false_rejected(self): + b = _valid_boundary(dry_lab_only=False) + assert any("dry_lab_only" in e for e in _errors(b)) + + def test_true_accepted(self): + b = _valid_boundary(dry_lab_only=True) + assert _errors(b) == [] + + def test_dry_lab_constant_name(self): + assert CCB_PREFIX == "CCB-" + + +# --------------------------------------------------------------------------- +# Group 10: Rule 11 — all_listed_classes_unsupported (3 tests) +# --------------------------------------------------------------------------- + +class TestAllListedClassesUnsupported: + def test_false_rejected(self): + b = _valid_boundary(all_listed_classes_unsupported=False) + assert any("all_listed_classes_unsupported" in e for e in _errors(b)) + + def test_true_accepted(self): + b = _valid_boundary(all_listed_classes_unsupported=True) + assert _errors(b) == [] + + def test_both_flags_true_accepted(self): + b = _valid_boundary(dry_lab_only=True, all_listed_classes_unsupported=True) + assert _errors(b) == [] + + +# --------------------------------------------------------------------------- +# Group 11: Rule 12 — no duplicate claim classes (3 tests) +# --------------------------------------------------------------------------- + +class TestNoDuplicateClasses: + def test_duplicate_rejected(self): + b = _valid_boundary( + unsupported_claim_classes=[ + "biological_activity", "biological_activity", "human_safety", "clinical_utility" + ] + ) + assert any("duplicates" in e for e in _errors(b)) + + def test_unique_classes_accepted(self): + b = _valid_boundary( + unsupported_claim_classes=["biological_activity", "human_safety", "clinical_utility"] + ) + assert _errors(b) == [] + + def test_all_unique_all_classes_accepted(self): + b = _valid_boundary(unsupported_claim_classes=_all_classes()) + assert _errors(b) == [] + + +# --------------------------------------------------------------------------- +# Group 12: Rule 13 — notes length (3 tests) +# --------------------------------------------------------------------------- + +class TestNotesLength: + def test_too_long_rejected(self): + b = _valid_boundary(notes="n" * (NOTES_MAX_LENGTH + 1)) + assert any("notes" in e for e in _errors(b)) + + def test_at_limit_accepted(self): + b = _valid_boundary(notes="n" * NOTES_MAX_LENGTH) + assert _errors(b) == [] + + def test_empty_notes_accepted(self): + b = _valid_boundary(notes="") + assert _errors(b) == [] + + +# --------------------------------------------------------------------------- +# Group 13: Warnings (6 tests) +# --------------------------------------------------------------------------- + +class TestWarnings: + def test_incomplete_classes_triggers_warning(self): + b = _valid_boundary( + unsupported_claim_classes=["biological_activity", "human_safety", "clinical_utility"] + ) + warns = _warns(b) + assert any("not listed as unsupported" in w for w in warns) + + def test_all_classes_listed_no_missing_warning(self): + b = _valid_boundary(unsupported_claim_classes=_all_classes()) + warns = _warns(b) + assert not any("not listed as unsupported" in w for w in warns) + + def test_empty_notes_triggers_warning(self): + b = _valid_boundary(notes="") + warns = _warns(b) + assert any("notes" in w.lower() for w in warns) + + def test_notes_present_suppresses_notes_warning(self): + b = _valid_boundary(notes="All classes documented for external review.") + warns = _warns(b) + assert not any("notes is empty" in w for w in warns) + + def test_all_classes_listed_with_notes_no_missing_warning(self): + b = _valid_boundary( + unsupported_claim_classes=_all_classes(), + notes="Full boundary documentation.", + ) + warns = _warns(b) + assert not any("not listed as unsupported" in w for w in warns) + + def test_partial_classes_exact_warning_count(self): + b = _valid_boundary( + unsupported_claim_classes=["biological_activity", "human_safety", "clinical_utility"] + ) + warns = _warns(b) + missing_warn = [w for w in warns if "not listed as unsupported" in w] + assert len(missing_warn) == 1 + + +# --------------------------------------------------------------------------- +# Group 14: validate_dict (4 tests) +# --------------------------------------------------------------------------- + +class TestValidateDict: + def test_valid_dict_returns_no_errors(self): + data = dict( + ccb_id="CCB-20240315-001", + pipeline_version="v2.1.0", + certificate_id="CERT-20240315-001", + candidate_id="AMPF-001", + boundary_date="2024-03-15", + unsupported_claim_classes=_all_classes(), + boundary_statement="This certificate does not support biological activity, safety, or clinical claims.", + dry_lab_only=True, + all_listed_classes_unsupported=True, + ) + errs = [e for e in validate_dict(data) if not e.startswith("WARNING:")] + assert errs == [] + + def test_missing_required_field_returns_error(self): + data = dict(ccb_id="CCB-001", pipeline_version="v1.0") + result = validate_dict(data) + assert any("Schema construction error" in e for e in result) + + def test_invalid_field_caught(self): + data = dict( + ccb_id="WRONG-001", + pipeline_version="v1.0", + certificate_id="CERT-001", + candidate_id="AMPF-001", + boundary_date="2024-01-01", + unsupported_claim_classes=_all_classes(), + boundary_statement="No claims supported.", + dry_lab_only=True, + all_listed_classes_unsupported=True, + ) + result = validate_dict(data) + assert any("ccb_id" in e for e in result) + + def test_extra_notes_in_dict(self): + data = dict( + ccb_id="CCB-20240315-002", + pipeline_version="v2.1.0", + certificate_id="CERT-20240315-002", + candidate_id="AMPF-002", + boundary_date="2024-03-15", + unsupported_claim_classes=_all_classes(), + boundary_statement="All 8 claim classes are unsupported. Computational predictions only.", + dry_lab_only=True, + all_listed_classes_unsupported=True, + notes="Issued for partner external review.", + ) + errs = [e for e in validate_dict(data) if not e.startswith("WARNING:")] + assert errs == [] + + +# --------------------------------------------------------------------------- +# Group 15: Edge cases (8 tests) +# --------------------------------------------------------------------------- + +class TestEdgeCases: + def test_all_claim_classes_count(self): + assert len(ALL_CLAIM_CLASSES) == 8 + + def test_minimum_unsupported_is_three(self): + assert MINIMUM_UNSUPPORTED_CLASSES == 3 + + def test_boundary_statement_one_below_limit(self): + b = _valid_boundary(boundary_statement="x" * (BOUNDARY_STATEMENT_MAX_LENGTH - 1)) + assert _errors(b) == [] + + def test_notes_one_below_limit(self): + b = _valid_boundary(notes="n" * (NOTES_MAX_LENGTH - 1)) + assert _errors(b) == [] + + def test_resistance_profile_is_valid_class(self): + b = _valid_boundary( + unsupported_claim_classes=[ + "resistance_profile", "biological_activity", "human_safety" + ] + ) + assert _errors(b) == [] + + def test_mechanism_proof_is_valid_class(self): + b = _valid_boundary( + unsupported_claim_classes=[ + "mechanism_proof", "biological_activity", "human_safety" + ] + ) + assert _errors(b) == [] + + def test_regulatory_approval_is_valid_class(self): + b = _valid_boundary( + unsupported_claim_classes=[ + "regulatory_approval", "biological_activity", "human_safety" + ] + ) + assert _errors(b) == [] + + def test_all_classes_in_all_claim_classes(self): + expected = { + "biological_activity", "human_safety", "clinical_utility", + "animal_efficacy", "therapeutic_indication", "regulatory_approval", + "mechanism_proof", "resistance_profile", + } + assert ALL_CLAIM_CLASSES == expected diff --git a/tests/test_test_count_regression.py b/tests/test_test_count_regression.py index e8a7f785..e75880ed 100644 --- a/tests/test_test_count_regression.py +++ b/tests/test_test_count_regression.py @@ -6,7 +6,7 @@ # Note: 3 pre-existing collection errors in test_benchmark_calibration.py, # test_benchmark_charge_distribution.py, test_benchmark_cheap_enemies.py. # Raise threshold if legitimate tests are removed; lower threshold is okay. -BASELINE = 6268 +BASELINE = 6331 TOLERANCE = 0.05 # allow 5% fluctuation