From 9d041143d216b19f4e35c5871dc7eb86a1147bde Mon Sep 17 00:00:00 2001 From: OpenCode Date: Fri, 10 Jul 2026 07:56:59 +0700 Subject: [PATCH] feat: Phase Q Q3 external sharing clearance schema -- auditable release gate before PEP reaches external lab partner, enforces dry-lab caveat confirmation --- .../EXTERNAL_SHARING_CLEARANCE_GUIDE.md | 68 +++ docs/evidence/METRICS_CURRENT.md | 3 +- docs/research/NEXT_100_PR_MAP.md | 1 + src/openamp_foundry/cli/commands/reports.py | 41 ++ src/openamp_foundry/cli/main.py | 15 +- .../evidence/external_sharing_clearance.py | 178 +++++++ .../test_external_sharing_clearance.py | 440 ++++++++++++++++++ tests/test_test_count_regression.py | 2 +- 8 files changed, 745 insertions(+), 3 deletions(-) create mode 100644 docs/evidence/EXTERNAL_SHARING_CLEARANCE_GUIDE.md create mode 100644 src/openamp_foundry/evidence/external_sharing_clearance.py create mode 100644 tests/evidence/test_external_sharing_clearance.py diff --git a/docs/evidence/EXTERNAL_SHARING_CLEARANCE_GUIDE.md b/docs/evidence/EXTERNAL_SHARING_CLEARANCE_GUIDE.md new file mode 100644 index 00000000..5dd13f29 --- /dev/null +++ b/docs/evidence/EXTERNAL_SHARING_CLEARANCE_GUIDE.md @@ -0,0 +1,68 @@ +# External Sharing Clearance Guide + +## Purpose + +An `ExternalSharingClearance` (ESC-) records the formal event of sharing a +`PilotEvidencePackage` with an external lab partner. It is the final release +gate in Phase Q — the moment that transforms an internal dry-lab artifact into +an externally shared package. + +No PEP should leave the foundry without an ESC record. + +## Fields + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `esc_id` | str | Yes | Unique ID, must start with `ESC-` | +| `pipeline_version` | str | Yes | Version of the pipeline that produced the PEP | +| `pep_id` | str | Yes | Which PEP is being shared (must start with `PEP-`) | +| `pre_id` | str | Yes | Linked pre-registration (must start with `PRE-`) | +| `external_lab_token` | str | Yes | Anonymized partner token — no PII | +| `sharing_date` | str | Yes | ISO YYYY-MM-DD date of sharing | +| `caveat_confirmed` | bool | Yes | Must be `True` — dry-lab caveat was communicated | +| `dry_lab_only_acknowledged` | bool | Yes | Must be `True` — partner acknowledged dry-lab status | +| `sharing_purpose` | str | Yes | `pilot_experiment`, `peer_review`, or `collaboration` | +| `sharing_notes` | str | Yes | Context notes (max 400 chars) | +| `reviewer` | str | Yes | Name of the human who authorized this sharing | +| `dry_lab_only` | bool | No | Default `True` — always True for this schema | + +## Validation rules + +1. `esc_id` must start with `ESC-` +2. `pep_id` must start with `PEP-` +3. `pre_id` must start with `PRE-` +4. `pipeline_version`, `external_lab_token`, `reviewer` must not be empty +5. `sharing_date` must be ISO format `YYYY-MM-DD` +6. `caveat_confirmed` must be `True` +7. `dry_lab_only_acknowledged` must be `True` +8. `sharing_purpose` must be one of `{pilot_experiment, peer_review, collaboration}` +9. `sharing_notes` must be at most 400 characters + +## Warnings + +- Empty `sharing_notes` — document the context +- `sharing_purpose = pilot_experiment` — extra reminder to confirm safety clearance with partner +- `sharing_purpose = collaboration` with no notes — document scope + +## Sharing purposes + +| Purpose | When to use | +|---------|-------------| +| `pilot_experiment` | Package is shared for actual wet-lab testing | +| `peer_review` | Package is shared for scientific peer review only | +| `collaboration` | Package is shared for joint research without immediate wet-lab commitment | + +## Honest boundaries + +- An ESC record does NOT guarantee the external partner will perform the experiment. +- An ESC record does NOT upgrade the proof level — all contents remain dry-lab-only. +- The `external_lab_token` must never contain PII (names, emails, institutions directly identifying individuals). +- An ESC with `caveat_confirmed=False` will always fail validation. + +## CLI usage + +```bash +openamp-foundry external-sharing-clearance-check \ + --entry-json '{"esc_id":"ESC-001","pipeline_version":"v0.10.8","pep_id":"PEP-001","pre_id":"PRE-001","external_lab_token":"LAB-A","sharing_date":"2026-07-10","caveat_confirmed":true,"dry_lab_only_acknowledged":true,"sharing_purpose":"pilot_experiment","sharing_notes":"MIC validation run.","reviewer":"Dr. Smith"}' \ + --format text +``` diff --git a/docs/evidence/METRICS_CURRENT.md b/docs/evidence/METRICS_CURRENT.md index 1a2557cf..2be84a31 100644 --- a/docs/evidence/METRICS_CURRENT.md +++ b/docs/evidence/METRICS_CURRENT.md @@ -5,7 +5,8 @@ Machine-readable snapshot: `outputs/metrics_snapshot.json` regenerated with `mak > **Purpose:** One authoritative table of current pipeline metrics. If any doc disagrees > with this file, this file wins. Updated whenever benchmark/benchmark config changes. > -> **Last updated:** 2026-07-10 (Phase Q Q2 — pre-registration entry — v0.10.8) +> **Last updated:** 2026-07-10 (Phase Q Q3 — external sharing clearance — v0.10.9) +> **New in v0.10.9 (Phase Q Q3):** ExternalSharingClearance — auditable release gate before external PEP sharing (12 fields, 9 validation rules, 3 warnings, 63 tests, BASELINE 5099→5162) > **New in v0.10.8 (Phase Q Q2):** PreRegistrationEntry — pre-experiment plan lock (14 fields, 9 validation rules, 4 warnings, 63 tests, BASELINE 5036→5099) > **New in v0.10.7 (Phase Q Q1):** PilotEvidencePackageEntry — external export bundle (14 fields, CCS+BSP+PSC+PRE+BCM refs, completeness+safety enforcement, 63 tests, BASELINE 4973→5036). > **New in v0.10.5 (Phase P P2):** RecalibrationRefusalEntry schema (10 fields: rrf_id, pipeline_version, trigger_id, recalibration_refused, refusal_reason, minimum_batches_required, batches_evaluated, refusal_notes, reviewer, dry_lab_only). recalibration_refused must be True — this schema only records valid refusals. 7 validation rules (RRF- prefix, CPS-/DRM- trigger prefix, recalibration_refused=True enforced, reason in 5 valid values, minimum_batches>=1, batches_evaluated>=0, notes<=400 chars). 2 warnings (insufficient_data but meets minimum; reviewer_override without documentation). Complements CalibrationImprovementRecord (O3). Added validate_recalibration_refusal() and validate_recalibration_refusal_dict(). Added 63 tests. CLI: openamp-foundry recalibration-refusal-check with --entry-json and --format text|json. Corrected inflated BASELINE from 4889 to 4784 (actual count 4721 + 63 = 4784). diff --git a/docs/research/NEXT_100_PR_MAP.md b/docs/research/NEXT_100_PR_MAP.md index cd6abb2a..f8674f73 100644 --- a/docs/research/NEXT_100_PR_MAP.md +++ b/docs/research/NEXT_100_PR_MAP.md @@ -274,6 +274,7 @@ bundle. |---:|---|---|---| | Q1 | Add pilot evidence package schema (complete). | External export artifact: bundles CCS+BSP+PSC+PRE+BCM references, enforces completeness and safety clearance before external sharing. | B/D | | Q2 | Add pre-registration entry schema (complete). | Locks hypothesis, endpoint, and candidate list before wet-lab begins; prevents HARKing; referenced by PEP as pre_registration_id. | B/D | +| Q3 | Add external sharing clearance schema (complete). | Auditable release gate: records who received a PEP, when, with what dry-lab caveat confirmation; no PEP leaves the foundry without an ESC record. | B/D | ## Prioritization rule diff --git a/src/openamp_foundry/cli/commands/reports.py b/src/openamp_foundry/cli/commands/reports.py index 40fc325c..500c7069 100644 --- a/src/openamp_foundry/cli/commands/reports.py +++ b/src/openamp_foundry/cli/commands/reports.py @@ -3785,3 +3785,44 @@ def _run_pre_registration_entry_check(args) -> int: for warn in result.warnings: print(f" WARN: {warn}") return 0 if result.passed else 1 + + +def _run_external_sharing_clearance_check(args): + from openamp_foundry.evidence.external_sharing_clearance import ( + validate_external_sharing_clearance_dict, + ) + import json + import sys + + data = json.loads(args.entry_json) + result = validate_external_sharing_clearance_dict(data) + + if args.format == "json": + out = { + "esc_id": result.esc_id, + "pep_id": result.pep_id, + "pre_id": result.pre_id, + "sharing_purpose": result.sharing_purpose, + "passed": result.passed, + "errors": result.errors, + "warnings": result.warnings, + "dry_lab_only": result.dry_lab_only, + } + print(json.dumps(out, indent=2)) + else: + status = "PASS" if result.passed else "FAIL" + print(f"External Sharing Clearance: {status}") + print(f" ESC ID: {result.esc_id}") + print(f" PEP ID: {result.pep_id}") + print(f" PRE ID: {result.pre_id}") + print(f" Purpose: {result.sharing_purpose}") + if result.errors: + print(" Errors:") + for e in result.errors: + print(f" - {e}") + if result.warnings: + print(" Warnings:") + for w in result.warnings: + print(f" - {w}") + + sys.exit(0 if result.passed else 1) diff --git a/src/openamp_foundry/cli/main.py b/src/openamp_foundry/cli/main.py index fa22fa16..689de8cf 100644 --- a/src/openamp_foundry/cli/main.py +++ b/src/openamp_foundry/cli/main.py @@ -17,7 +17,8 @@ _run_simulation_scope_check, _run_simulation_evidence_packet, _run_artifact_version, _run_candidate_manifest, _run_benchmark_card, _run_artifact_changelog, _run_integration_check, _run_adapter_check, _run_license_check, _run_artifact_compat_check, _run_adoption_scorecard, - _run_reviewer_briefing_check, _run_audit_chain_check, _run_pre_registration_check, + _run_reviewer_briefing_check, _run_audit_chain_check, _run_pre_registration_check, + _run_external_sharing_clearance_check, _run_hypothesis_outcome_check, _run_baseline_comparison_check, _run_negative_result_check, _run_experiment_priority_check, _run_calibration_performance_check, _run_prediction_drift_check, _run_calibration_improvement_check, @@ -2278,6 +2279,15 @@ def build_parser() -> argparse.ArgumentParser: ) pre_registration_parser.set_defaults(func=_run_pre_registration_check) + external_sharing_clearance_parser = sub.add_parser( + "external-sharing-clearance-check", + help="Validate an ExternalSharingClearance entry (ESC-).", + ) + external_sharing_clearance_parser.add_argument("--entry-json", required=True) + external_sharing_clearance_parser.add_argument( + "--format", choices=["text", "json"], default="text" + ) + # ── Hypothesis outcome check (Phase N N2) ──────────────────────── hypothesis_outcome_parser = sub.add_parser( "hypothesis-outcome-check", @@ -2772,6 +2782,9 @@ def main(argv: list[str] | None = None) -> int: if args.command == "pre-registration-check": return _run_pre_registration_check(args) + if args.command == "external-sharing-clearance-check": + return _run_external_sharing_clearance_check(args) + if args.command == "hypothesis-outcome-check": return _run_hypothesis_outcome_check(args) diff --git a/src/openamp_foundry/evidence/external_sharing_clearance.py b/src/openamp_foundry/evidence/external_sharing_clearance.py new file mode 100644 index 00000000..6cbe61f2 --- /dev/null +++ b/src/openamp_foundry/evidence/external_sharing_clearance.py @@ -0,0 +1,178 @@ +"""External sharing clearance schema — Phase Q Q3. + +Records the formal clearance event when a PilotEvidencePackage (PEP) is +shared with an external lab partner. Makes external sharing auditable: +the dry-lab-only caveat must be confirmed, the external partner must be +identified by anonymous token, and the sharing event is timestamped. + +This is the final release gate in Phase Q — no PEP should leave the +foundry without an ESC record. +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass, field +from typing import List + +ESC_PREFIX = "ESC-" +PEP_PREFIX = "PEP-" +PRE_PREFIX = "PRE-" + +VALID_SHARING_PURPOSES = frozenset({ + "pilot_experiment", + "peer_review", + "collaboration", +}) + +NOTES_MAX_LENGTH = 400 + + +@dataclass +class ExternalSharingClearance: + """Clearance record for sharing a PEP with an external lab partner.""" + + esc_id: str + pipeline_version: str + pep_id: str # must start with "PEP-" + pre_id: str # must start with "PRE-" + external_lab_token: str # anonymized lab identifier, no PII + sharing_date: str # ISO YYYY-MM-DD + caveat_confirmed: bool # must be True — dry-lab-only caveat was communicated + dry_lab_only_acknowledged: bool # must be True — partner acknowledged dry-lab status + sharing_purpose: str # {pilot_experiment, peer_review, collaboration} + sharing_notes: str # max 400 chars + reviewer: str + dry_lab_only: bool = True + + +@dataclass +class ExternalSharingClearanceResult: + esc_id: str + pep_id: str + pre_id: str + sharing_purpose: str + passed: bool + errors: List[str] = field(default_factory=list) + warnings: List[str] = field(default_factory=list) + dry_lab_only: bool = True + + +def validate_external_sharing_clearance( + entry: ExternalSharingClearance, +) -> ExternalSharingClearanceResult: + errors: List[str] = [] + warnings: List[str] = [] + + # Rule 1: ESC- prefix + if not entry.esc_id.startswith(ESC_PREFIX): + errors.append( + f"esc_id must start with '{ESC_PREFIX}', got '{entry.esc_id}'" + ) + + # Rule 2: PEP- prefix + if not entry.pep_id.startswith(PEP_PREFIX): + errors.append( + f"pep_id must start with '{PEP_PREFIX}', got '{entry.pep_id}'" + ) + + # Rule 3: PRE- prefix + if not entry.pre_id.startswith(PRE_PREFIX): + errors.append( + f"pre_id must start with '{PRE_PREFIX}', got '{entry.pre_id}'" + ) + + # Rule 4: non-empty required strings + for fname, val in [ + ("pipeline_version", entry.pipeline_version), + ("external_lab_token", entry.external_lab_token), + ("reviewer", entry.reviewer), + ]: + if not val or not val.strip(): + errors.append(f"{fname} must not be empty") + + # Rule 5: sharing_date must be ISO YYYY-MM-DD + if not re.match(r"^\d{4}-\d{2}-\d{2}$", entry.sharing_date): + errors.append( + f"sharing_date must be ISO format YYYY-MM-DD, got '{entry.sharing_date}'" + ) + + # Rule 6: caveat_confirmed must be True + if not entry.caveat_confirmed: + errors.append( + "caveat_confirmed must be True — dry-lab-only caveat must be communicated" + " before external sharing" + ) + + # Rule 7: dry_lab_only_acknowledged must be True + if not entry.dry_lab_only_acknowledged: + errors.append( + "dry_lab_only_acknowledged must be True — external partner must acknowledge" + " dry-lab-only status before receiving the package" + ) + + # Rule 8: valid sharing purpose + if entry.sharing_purpose not in VALID_SHARING_PURPOSES: + errors.append( + f"sharing_purpose must be one of {sorted(VALID_SHARING_PURPOSES)}, " + f"got '{entry.sharing_purpose}'" + ) + + # Rule 9: notes length + if len(entry.sharing_notes) > NOTES_MAX_LENGTH: + errors.append( + f"sharing_notes must be at most {NOTES_MAX_LENGTH} characters, " + f"got {len(entry.sharing_notes)}" + ) + + # Warning 1: no sharing_notes provided + if not entry.sharing_notes.strip(): + warnings.append( + "sharing_notes is empty — consider documenting the context of this sharing event" + ) + + # Warning 2: pilot_experiment is highest stakes + if entry.sharing_purpose == "pilot_experiment": + warnings.append( + "sharing_purpose is 'pilot_experiment' — confirm wet-lab partner has reviewed" + " all safety clearance artifacts before proceeding" + ) + + # Warning 3: collaboration without notes is unusual + if entry.sharing_purpose == "collaboration" and not entry.sharing_notes.strip(): + warnings.append( + "sharing_purpose is 'collaboration' with no sharing_notes — document the" + " scope of the collaboration" + ) + + passed = len(errors) == 0 + return ExternalSharingClearanceResult( + esc_id=entry.esc_id, + pep_id=entry.pep_id, + pre_id=entry.pre_id, + sharing_purpose=entry.sharing_purpose, + passed=passed, + errors=errors, + warnings=warnings, + dry_lab_only=entry.dry_lab_only, + ) + + +def validate_external_sharing_clearance_dict( + data: dict, +) -> ExternalSharingClearanceResult: + entry = ExternalSharingClearance( + esc_id=data.get("esc_id", ""), + pipeline_version=data.get("pipeline_version", ""), + pep_id=data.get("pep_id", ""), + pre_id=data.get("pre_id", ""), + external_lab_token=data.get("external_lab_token", ""), + sharing_date=data.get("sharing_date", ""), + caveat_confirmed=bool(data.get("caveat_confirmed", False)), + dry_lab_only_acknowledged=bool(data.get("dry_lab_only_acknowledged", False)), + sharing_purpose=data.get("sharing_purpose", ""), + sharing_notes=data.get("sharing_notes", ""), + reviewer=data.get("reviewer", ""), + dry_lab_only=bool(data.get("dry_lab_only", True)), + ) + return validate_external_sharing_clearance(entry) diff --git a/tests/evidence/test_external_sharing_clearance.py b/tests/evidence/test_external_sharing_clearance.py new file mode 100644 index 00000000..19d7520f --- /dev/null +++ b/tests/evidence/test_external_sharing_clearance.py @@ -0,0 +1,440 @@ +"""Tests for ExternalSharingClearance schema — Phase Q Q3.""" + +from __future__ import annotations + +import pytest + +from openamp_foundry.evidence.external_sharing_clearance import ( + ExternalSharingClearance, + ExternalSharingClearanceResult, + validate_external_sharing_clearance, + validate_external_sharing_clearance_dict, +) + + +def _valid_entry(**overrides) -> ExternalSharingClearance: + defaults = dict( + esc_id="ESC-001", + pipeline_version="v0.10.8", + pep_id="PEP-001", + pre_id="PRE-001", + external_lab_token="LAB-TOKEN-A", + sharing_date="2026-07-10", + caveat_confirmed=True, + dry_lab_only_acknowledged=True, + sharing_purpose="pilot_experiment", + sharing_notes="Sharing for MIC validation of top 5 candidates.", + reviewer="Dr. Smith", + dry_lab_only=True, + ) + defaults.update(overrides) + return ExternalSharingClearance(**defaults) + + +# ── 1. ESC ID validation ───────────────────────────────────────────────────── + +class TestEscIdValidation: + def test_valid_esc_prefix(self): + result = validate_external_sharing_clearance(_valid_entry()) + assert result.passed + + def test_wrong_prefix_fails(self): + result = validate_external_sharing_clearance(_valid_entry(esc_id="REF-001")) + assert not result.passed + assert any("esc_id must start with 'ESC-'" in e for e in result.errors) + + def test_empty_esc_id_fails(self): + result = validate_external_sharing_clearance(_valid_entry(esc_id="")) + assert not result.passed + assert any("esc_id must start with 'ESC-'" in e for e in result.errors) + + def test_lowercase_prefix_fails(self): + result = validate_external_sharing_clearance(_valid_entry(esc_id="esc-001")) + assert not result.passed + + def test_esc_prefix_with_long_id(self): + result = validate_external_sharing_clearance(_valid_entry(esc_id="ESC-20260710-001")) + assert result.passed + + def test_esc_prefix_captured_in_result(self): + result = validate_external_sharing_clearance(_valid_entry(esc_id="ESC-XYZ")) + assert result.esc_id == "ESC-XYZ" + + def test_no_prefix_just_number_fails(self): + result = validate_external_sharing_clearance(_valid_entry(esc_id="001")) + assert not result.passed + + +# ── 2. PEP ID validation ───────────────────────────────────────────────────── + +class TestPepIdValidation: + def test_valid_pep_prefix(self): + result = validate_external_sharing_clearance(_valid_entry()) + assert result.passed + + def test_wrong_pep_prefix_fails(self): + result = validate_external_sharing_clearance(_valid_entry(pep_id="BCM-001")) + assert not result.passed + assert any("pep_id must start with 'PEP-'" in e for e in result.errors) + + def test_empty_pep_id_fails(self): + result = validate_external_sharing_clearance(_valid_entry(pep_id="")) + assert not result.passed + + def test_pep_id_captured_in_result(self): + result = validate_external_sharing_clearance(_valid_entry(pep_id="PEP-999")) + assert result.pep_id == "PEP-999" + + def test_lowercase_pep_fails(self): + result = validate_external_sharing_clearance(_valid_entry(pep_id="pep-001")) + assert not result.passed + + def test_pep_long_id_passes(self): + result = validate_external_sharing_clearance(_valid_entry(pep_id="PEP-2026-07-10-001")) + assert result.passed + + +# ── 3. PRE ID validation ───────────────────────────────────────────────────── + +class TestPreIdValidation: + def test_valid_pre_prefix(self): + result = validate_external_sharing_clearance(_valid_entry()) + assert result.passed + + def test_wrong_pre_prefix_fails(self): + result = validate_external_sharing_clearance(_valid_entry(pre_id="BSP-001")) + assert not result.passed + assert any("pre_id must start with 'PRE-'" in e for e in result.errors) + + def test_empty_pre_id_fails(self): + result = validate_external_sharing_clearance(_valid_entry(pre_id="")) + assert not result.passed + + def test_pre_id_captured_in_result(self): + result = validate_external_sharing_clearance(_valid_entry(pre_id="PRE-999")) + assert result.pre_id == "PRE-999" + + def test_lowercase_pre_fails(self): + result = validate_external_sharing_clearance(_valid_entry(pre_id="pre-001")) + assert not result.passed + + def test_pre_long_id_passes(self): + result = validate_external_sharing_clearance(_valid_entry(pre_id="PRE-2026-07-10-001")) + assert result.passed + + +# ── 4. Required fields validation ──────────────────────────────────────────── + +class TestRequiredFields: + def test_all_fields_present_passes(self): + result = validate_external_sharing_clearance(_valid_entry()) + assert result.passed + + def test_empty_pipeline_version_fails(self): + result = validate_external_sharing_clearance(_valid_entry(pipeline_version="")) + assert not result.passed + assert any("pipeline_version must not be empty" in e for e in result.errors) + + def test_whitespace_pipeline_version_fails(self): + result = validate_external_sharing_clearance(_valid_entry(pipeline_version=" ")) + assert not result.passed + + def test_empty_external_lab_token_fails(self): + result = validate_external_sharing_clearance(_valid_entry(external_lab_token="")) + assert not result.passed + assert any("external_lab_token must not be empty" in e for e in result.errors) + + def test_whitespace_lab_token_fails(self): + result = validate_external_sharing_clearance(_valid_entry(external_lab_token=" ")) + assert not result.passed + + def test_empty_reviewer_fails(self): + result = validate_external_sharing_clearance(_valid_entry(reviewer="")) + assert not result.passed + assert any("reviewer must not be empty" in e for e in result.errors) + + def test_whitespace_reviewer_fails(self): + result = validate_external_sharing_clearance(_valid_entry(reviewer=" ")) + assert not result.passed + + def test_multiple_empty_required_fields_all_reported(self): + result = validate_external_sharing_clearance( + _valid_entry(pipeline_version="", reviewer="") + ) + assert not result.passed + assert len([e for e in result.errors if "must not be empty" in e]) == 2 + + def test_valid_minimal_sharing_notes(self): + result = validate_external_sharing_clearance(_valid_entry(sharing_notes="")) + # empty notes is a warning, not an error + assert result.passed + assert any("sharing_notes is empty" in w for w in result.warnings) + + +# ── 5. Sharing date format ──────────────────────────────────────────────────── + +class TestSharingDateFormat: + def test_valid_iso_date(self): + result = validate_external_sharing_clearance(_valid_entry(sharing_date="2026-07-10")) + assert result.passed + + def test_date_without_dashes_fails(self): + result = validate_external_sharing_clearance(_valid_entry(sharing_date="20260710")) + assert not result.passed + assert any("sharing_date must be ISO format" in e for e in result.errors) + + def test_date_slash_separator_fails(self): + result = validate_external_sharing_clearance(_valid_entry(sharing_date="2026/07/10")) + assert not result.passed + + def test_empty_date_fails(self): + result = validate_external_sharing_clearance(_valid_entry(sharing_date="")) + assert not result.passed + + def test_partial_date_fails(self): + result = validate_external_sharing_clearance(_valid_entry(sharing_date="2026-07")) + assert not result.passed + + def test_text_date_fails(self): + result = validate_external_sharing_clearance(_valid_entry(sharing_date="July 10 2026")) + assert not result.passed + + def test_another_valid_date(self): + result = validate_external_sharing_clearance(_valid_entry(sharing_date="2025-01-01")) + assert result.passed + + +# ── 6. Caveat confirmed ─────────────────────────────────────────────────────── + +class TestCaveatConfirmed: + def test_caveat_true_passes(self): + result = validate_external_sharing_clearance(_valid_entry(caveat_confirmed=True)) + assert result.passed + + def test_caveat_false_fails(self): + result = validate_external_sharing_clearance(_valid_entry(caveat_confirmed=False)) + assert not result.passed + assert any("caveat_confirmed must be True" in e for e in result.errors) + + def test_caveat_false_is_blocking(self): + # even with all other fields valid, caveat_false blocks the record + entry = _valid_entry(caveat_confirmed=False) + result = validate_external_sharing_clearance(entry) + assert not result.passed + + def test_caveat_confirmed_in_result(self): + # result does not expose caveat_confirmed directly but passed reflects it + result = validate_external_sharing_clearance(_valid_entry(caveat_confirmed=True)) + assert result.passed + + def test_both_caveats_false_both_reported(self): + result = validate_external_sharing_clearance( + _valid_entry(caveat_confirmed=False, dry_lab_only_acknowledged=False) + ) + assert not result.passed + assert any("caveat_confirmed" in e for e in result.errors) + assert any("dry_lab_only_acknowledged" in e for e in result.errors) + + +# ── 7. Dry-lab-only acknowledged ───────────────────────────────────────────── + +class TestDryLabOnlyAcknowledged: + def test_acknowledged_true_passes(self): + result = validate_external_sharing_clearance( + _valid_entry(dry_lab_only_acknowledged=True) + ) + assert result.passed + + def test_acknowledged_false_fails(self): + result = validate_external_sharing_clearance( + _valid_entry(dry_lab_only_acknowledged=False) + ) + assert not result.passed + assert any("dry_lab_only_acknowledged must be True" in e for e in result.errors) + + def test_not_acknowledged_is_blocking(self): + result = validate_external_sharing_clearance( + _valid_entry(dry_lab_only_acknowledged=False) + ) + assert not result.passed + + def test_result_dry_lab_only_flag(self): + result = validate_external_sharing_clearance(_valid_entry(dry_lab_only=True)) + assert result.dry_lab_only is True + + def test_result_dry_lab_false_flag(self): + result = validate_external_sharing_clearance(_valid_entry(dry_lab_only=False)) + assert result.dry_lab_only is False + + +# ── 8. Sharing purpose validation ──────────────────────────────────────────── + +class TestSharingPurpose: + def test_pilot_experiment_passes(self): + result = validate_external_sharing_clearance( + _valid_entry(sharing_purpose="pilot_experiment") + ) + assert result.passed + + def test_peer_review_passes(self): + result = validate_external_sharing_clearance( + _valid_entry(sharing_purpose="peer_review", sharing_notes="For peer review.") + ) + assert result.passed + + def test_collaboration_passes(self): + result = validate_external_sharing_clearance( + _valid_entry(sharing_purpose="collaboration", sharing_notes="Joint project.") + ) + assert result.passed + + def test_invalid_purpose_fails(self): + result = validate_external_sharing_clearance( + _valid_entry(sharing_purpose="publication") + ) + assert not result.passed + assert any("sharing_purpose must be one of" in e for e in result.errors) + + def test_empty_purpose_fails(self): + result = validate_external_sharing_clearance(_valid_entry(sharing_purpose="")) + assert not result.passed + + def test_purpose_captured_in_result(self): + result = validate_external_sharing_clearance( + _valid_entry(sharing_purpose="peer_review", sharing_notes="Review notes.") + ) + assert result.sharing_purpose == "peer_review" + + def test_pilot_experiment_generates_warning(self): + result = validate_external_sharing_clearance( + _valid_entry(sharing_purpose="pilot_experiment") + ) + assert any("pilot_experiment" in w for w in result.warnings) + + +# ── 9. Sharing notes ───────────────────────────────────────────────────────── + +class TestSharingNotes: + def test_valid_notes_passes(self): + result = validate_external_sharing_clearance( + _valid_entry(sharing_notes="Sharing for MIC validation.") + ) + assert result.passed + + def test_empty_notes_triggers_warning_not_error(self): + result = validate_external_sharing_clearance(_valid_entry(sharing_notes="")) + assert result.passed + assert any("sharing_notes is empty" in w for w in result.warnings) + + def test_notes_at_limit_passes(self): + notes = "x" * 400 + result = validate_external_sharing_clearance(_valid_entry(sharing_notes=notes)) + assert result.passed + + def test_notes_over_limit_fails(self): + notes = "x" * 401 + result = validate_external_sharing_clearance(_valid_entry(sharing_notes=notes)) + assert not result.passed + assert any("sharing_notes must be at most 400" in e for e in result.errors) + + def test_notes_just_over_limit_fails(self): + notes = "y" * 401 + result = validate_external_sharing_clearance(_valid_entry(sharing_notes=notes)) + assert not result.passed + + def test_whitespace_only_notes_triggers_warning(self): + result = validate_external_sharing_clearance(_valid_entry(sharing_notes=" ")) + # whitespace-only is empty for warning purposes + assert result.passed + assert any("sharing_notes is empty" in w for w in result.warnings) + + +# ── 10. Dict-based validator ───────────────────────────────────────────────── + +class TestDictValidator: + def test_valid_dict_passes(self): + data = { + "esc_id": "ESC-001", + "pipeline_version": "v0.10.8", + "pep_id": "PEP-001", + "pre_id": "PRE-001", + "external_lab_token": "LAB-A", + "sharing_date": "2026-07-10", + "caveat_confirmed": True, + "dry_lab_only_acknowledged": True, + "sharing_purpose": "pilot_experiment", + "sharing_notes": "Sharing candidates.", + "reviewer": "Dr. Smith", + "dry_lab_only": True, + } + result = validate_external_sharing_clearance_dict(data) + assert result.passed + + def test_dict_missing_esc_id_fails(self): + data = { + "esc_id": "WRONG-001", + "pipeline_version": "v0.10.8", + "pep_id": "PEP-001", + "pre_id": "PRE-001", + "external_lab_token": "LAB-A", + "sharing_date": "2026-07-10", + "caveat_confirmed": True, + "dry_lab_only_acknowledged": True, + "sharing_purpose": "pilot_experiment", + "sharing_notes": "ok", + "reviewer": "Dr. Smith", + } + result = validate_external_sharing_clearance_dict(data) + assert not result.passed + + def test_dict_defaults_dry_lab_only_true(self): + data = { + "esc_id": "ESC-001", + "pipeline_version": "v0.10.8", + "pep_id": "PEP-001", + "pre_id": "PRE-001", + "external_lab_token": "LAB-A", + "sharing_date": "2026-07-10", + "caveat_confirmed": True, + "dry_lab_only_acknowledged": True, + "sharing_purpose": "pilot_experiment", + "sharing_notes": "ok", + "reviewer": "Dr. Jones", + } + result = validate_external_sharing_clearance_dict(data) + assert result.dry_lab_only is True + + def test_dict_caveat_false_fails(self): + data = { + "esc_id": "ESC-001", + "pipeline_version": "v0.10.8", + "pep_id": "PEP-001", + "pre_id": "PRE-001", + "external_lab_token": "LAB-A", + "sharing_date": "2026-07-10", + "caveat_confirmed": False, + "dry_lab_only_acknowledged": True, + "sharing_purpose": "pilot_experiment", + "sharing_notes": "ok", + "reviewer": "Dr. Jones", + } + result = validate_external_sharing_clearance_dict(data) + assert not result.passed + + def test_dict_multiple_errors_reported(self): + data = { + "esc_id": "BAD-001", + "pipeline_version": "", + "pep_id": "BAD-001", + "pre_id": "BAD-001", + "external_lab_token": "LAB-A", + "sharing_date": "not-a-date", + "caveat_confirmed": False, + "dry_lab_only_acknowledged": False, + "sharing_purpose": "invalid", + "sharing_notes": "", + "reviewer": "", + } + result = validate_external_sharing_clearance_dict(data) + assert not result.passed + assert len(result.errors) >= 5 diff --git a/tests/test_test_count_regression.py b/tests/test_test_count_regression.py index e62df0f5..acbc4216 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 = 5099 +BASELINE = 5162 TOLERANCE = 0.05 # allow 5% fluctuation